diff --git a/.github/workflows/screenshot-test.yml b/.github/workflows/screenshot-test.yml index 6354e498a4dfa..937f95e700589 100644 --- a/.github/workflows/screenshot-test.yml +++ b/.github/workflows/screenshot-test.yml @@ -138,17 +138,23 @@ jobs: - name: Diff screenshots against merge base id: diff - if: github.event_name == 'pull_request' && steps.oidc.outputs.token + if: steps.oidc.outputs.token run: | - # We diff screenshots(checked-out commit) vs screenshots(merge-base of - # that commit with the target branch). This isolates the visual effect - # of just this PR's divergence from target. Using pull_request.base.sha - # would be wrong: it's the target-branch tip at PR creation time and can - # be stale, causing unrelated target-branch commits to show up as diffs. - TARGET_REF="origin/${{ github.event.pull_request.base.ref }}" - git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.ref }}" - BASE_SHA=$(git merge-base "${{ github.sha }}" "$TARGET_REF") - echo "Using base SHA: $BASE_SHA (merge-base of ${{ github.sha }} and $TARGET_REF)" + if [ "${{ github.event_name }}" = "pull_request" ]; then + # For PRs, diff against the merge-base with the target branch. + # This isolates the visual effect of just this PR's divergence + # from target. Using pull_request.base.sha would be wrong: it's + # the target-branch tip at PR creation time and can be stale, + # causing unrelated target-branch commits to show up as diffs. + TARGET_REF="origin/${{ github.event.pull_request.base.ref }}" + git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.ref }}" + BASE_SHA=$(git merge-base "${{ github.sha }}" "$TARGET_REF") + else + # For push events, diff against the parent commit. + BASE_SHA=$(git rev-parse "${{ github.sha }}^") + fi + echo "base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT" + echo "Using base SHA: $BASE_SHA (base for ${{ github.sha }})" BODY=$(node build/lib/screenshotDiffReport.ts \ https://hediet-screenshots.azurewebsites.net \ ${{ github.repository_owner }} \ @@ -172,8 +178,25 @@ jobs: env: SCREENSHOT_SERVICE_TOKEN: ${{ steps.oidc.outputs.token }} + - name: Write job summary + if: steps.diff.outputs.has_changes == 'true' || steps.blocks-ci.outputs.match == 'false' + run: | + BODY="${COMMENT_BODY}" + if [ -n "$BLOCKS_CI_CONTENT" ]; then + if [ -n "$BODY" ]; then BODY+=$'\n\n---\n\n'; fi + BODY+="### blocks-ci screenshots changed"$'\n\n' + BODY+="Replace the contents of \`test/componentFixtures/blocks-ci-screenshots.md\` with:"$'\n\n' + BODY+="
"$'\n'"Updated blocks-ci-screenshots.md"$'\n\n' + BODY+="\`\`\`md"$'\n'"${BLOCKS_CI_CONTENT}"$'\n'"\`\`\`"$'\n\n' + BODY+="
" + fi + echo "$BODY" >> "$GITHUB_STEP_SUMMARY" + env: + COMMENT_BODY: ${{ steps.diff.outputs.body }} + BLOCKS_CI_CONTENT: ${{ steps.blocks-ci.outputs.content }} + - name: Post PR comment - if: github.event_name == 'pull_request' && (steps.diff.outputs.has_changes == 'true' || steps.blocks-ci.outputs.match == 'false') + if: github.event_name == 'pull_request' uses: actions/github-script@v9 with: script: | @@ -190,7 +213,7 @@ jobs: body += ''; } - body = marker + '\n' + body; + const hasContent = body || blocksCiContent; const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, @@ -200,6 +223,27 @@ jobs: }); const existing = comments.find(c => c.body?.startsWith(marker)); + if (!hasContent) { + // No changes to report — update existing comment if present, otherwise do nothing + if (existing) { + const baseSha = (process.env.BASE_SHA || '').slice(0, 8); + const currentSha = (process.env.CURRENT_SHA || '').slice(0, 8); + let noChangesBody = '~No screenshot changes.~'; + if (baseSha && currentSha) { + noChangesBody = `**Base:** \`${baseSha}\` **Current:** \`${currentSha}\`\n\n` + noChangesBody; + } + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: marker + '\n' + noChangesBody, + }); + } + return; + } + + body = marker + '\n' + body; + if (existing) { await github.rest.issues.updateComment({ owner: context.repo.owner, @@ -218,6 +262,8 @@ jobs: env: COMMENT_BODY: ${{ steps.diff.outputs.body }} BLOCKS_CI_CONTENT: ${{ steps.blocks-ci.outputs.content }} + BASE_SHA: ${{ steps.diff.outputs.base_sha }} + CURRENT_SHA: ${{ github.sha }} - name: Fail if blocks-ci hashes changed if: steps.blocks-ci.outputs.match == 'false' diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 93862f3137794..5e8a2c7a25eab 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -1894,9 +1894,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.75" +version = "0.10.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" dependencies = [ "bitflags 2.10.0", "cfg-if", @@ -1926,9 +1926,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" -version = "0.9.111" +version = "0.9.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" dependencies = [ "cc", "libc", diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 6a7d00d955ac0..5fbdd80f6a69d 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -4018,6 +4018,15 @@ "tags": [ "experimental" ] + }, + "github.copilot.chat.localIndex.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%github.copilot.config.localIndex.enabled%", + "tags": [ + "experimental", + "onExp" + ] } } }, diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 26cd798a0a55b..6d6f23d955764 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -169,10 +169,11 @@ "copilot.agent.description": "Edit files in your workspace in agent mode", "copilot.agent.compact.description": "Free up context by compacting the conversation history. Optionally include extra instructions for compaction.", "copilot.chronicle.description": "Session history tools and insights", - "copilot.chronicle.standup.description": "Generate a standup report from recent coding sessions", - "copilot.chronicle.tips.description": "Get personalized tips based on your Copilot usage patterns", + "copilot.chronicle.standup.description": "Generate a standup report from recent chat sessions", + "copilot.chronicle.tips.description": "Get personalized tips based on your chat session usage patterns", "github.copilot.config.sessionSearch.enabled": "Enable session search and /chronicle commands. This is a team-internal setting.", "github.copilot.config.sessionSearch.localIndex.enabled": "Enable local session tracking. When enabled, Copilot tracks session data locally for /chronicle commands.", + "github.copilot.config.localIndex.enabled": "Enable local session tracking. When enabled, session data is tracked locally for /chronicle commands.", "github.copilot.config.sessionSearch.cloudSync.enabled": "Enable cloud sync for session data. When enabled, session data is synced to your Copilot account for cross-device access.", "github.copilot.config.sessionSearch.cloudSync.excludeRepositories": "Repository patterns to exclude from cloud sync. Use exact `owner/repo` names or glob patterns like `my-org/*`. Sessions from matching repos will only be stored locally.", "copilot.workspace.explain.description": "Explain how the code in your active editor works", diff --git a/extensions/copilot/src/extension/agents/vscode-node/planAgentProvider.ts b/extensions/copilot/src/extension/agents/vscode-node/planAgentProvider.ts index 4ef9f2fb018b8..8bb8006c81bee 100644 --- a/extensions/copilot/src/extension/agents/vscode-node/planAgentProvider.ts +++ b/extensions/copilot/src/extension/agents/vscode-node/planAgentProvider.ts @@ -9,6 +9,7 @@ import { AGENT_FILE_EXTENSION } from '../../../platform/customInstructions/commo import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; import { ILogService } from '../../../platform/log/common/logService'; +import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { AgentConfig, AgentHandoff, buildAgentMarkdown, DEFAULT_READ_TOOLS } from './agentTypes'; @@ -22,10 +23,8 @@ const BASE_PLAN_AGENT_CONFIG: AgentConfig = { argumentHint: 'Outline the goal or problem to research', target: 'vscode', disableModelInvocation: true, - agents: ['Explore'], tools: [ ...DEFAULT_READ_TOOLS, - 'agent', ], handoffs: [], // Handoffs are generated dynamically in buildCustomizedConfig body: '' // Body is generated dynamically in buildCustomizedConfig @@ -52,6 +51,7 @@ export class PlanAgentProvider extends Disposable implements vscode.ChatCustomAg @IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext, @IFileSystemService private readonly fileSystemService: IFileSystemService, @ILogService private readonly logService: ILogService, + @IExperimentationService private readonly experimentationService: IExperimentationService, ) { super(); @@ -63,7 +63,9 @@ export class PlanAgentProvider extends Disposable implements vscode.ChatCustomAg if (e.affectsConfiguration(ConfigKey.PlanAgentAdditionalTools.fullyQualifiedId) || e.affectsConfiguration(ConfigKey.Deprecated.PlanAgentModel.fullyQualifiedId) || e.affectsConfiguration('chat.planAgent.defaultModel') || - e.affectsConfiguration(ConfigKey.ImplementAgentModel.fullyQualifiedId)) { + e.affectsConfiguration(ConfigKey.ImplementAgentModel.fullyQualifiedId) || + e.affectsConfiguration(ConfigKey.ExploreAgentEnabled.fullyQualifiedId) || + e.affectsConfiguration(ConfigKey.Advanced.SearchSubagentToolEnabled.fullyQualifiedId)) { this._onDidChangeCustomAgents.fire(); } })); @@ -103,12 +105,27 @@ export class PlanAgentProvider extends Disposable implements vscode.ChatCustomAg return fileUri; } - static buildAgentBody(): string { - const discoverySection = `## 1. Discovery + static buildAgentBody(exploreEnabled: boolean, searchSubagentEnabled: boolean): string { + let discoverySection: string; + if (exploreEnabled) { + discoverySection = `## 1. Discovery Run the *Explore* subagent to gather context, analogous existing features to use as implementation templates, and potential blockers or ambiguities. When the task spans multiple independent areas (e.g., frontend + backend, different features, separate repos), launch **2-3 *Explore* subagents in parallel** — one per area — to speed up discovery. Update the plan with your findings.`; + } else if (searchSubagentEnabled) { + discoverySection = `## 1. Discovery + +Use #tool:searchSubagent to gather context, analogous existing features to use as implementation templates, and potential blockers or ambiguities. When the task spans multiple independent areas (e.g., frontend + backend, different features, separate repos), launch **2-3 search subagents in parallel** — one per area — to speed up discovery. + +Update the plan with your findings.`; + } else { + discoverySection = `## 1. Discovery + +Search the codebase to gather context, analogous existing features to use as implementation templates, and potential blockers or ambiguities. + +Update the plan with your findings.`; + } return `You are a PLANNING AGENT, pairing with the user to create a detailed, actionable plan. @@ -197,6 +214,8 @@ Rules: private buildCustomizedConfig(): AgentConfig { const additionalTools = this.configurationService.getConfig(ConfigKey.PlanAgentAdditionalTools); + const isExploreEnabled = this.configurationService.getExperimentBasedConfig(ConfigKey.ExploreAgentEnabled, this.experimentationService); + const isSearchSubagentEnabled = this.configurationService.getExperimentBasedConfig(ConfigKey.Advanced.SearchSubagentToolEnabled, this.experimentationService); const coreDefaultModel = this.configurationService.getNonExtensionConfig('chat.planAgent.defaultModel'); const modelOverride = coreDefaultModel || this.configurationService.getConfig(ConfigKey.Deprecated.PlanAgentModel); @@ -225,6 +244,11 @@ Rules: // Always include askQuestions tool (now provided by core) toolsToAdd.push('vscode/askQuestions'); + // When explore agent is enabled, include the 'agent' tool to allow sub-agent calls + if (isExploreEnabled) { + toolsToAdd.push('agent'); + } + // Merge additional tools (deduplicated) const tools = toolsToAdd.length > 0 ? [...new Set([...BASE_PLAN_AGENT_CONFIG.tools, ...toolsToAdd])] @@ -233,9 +257,11 @@ Rules: // Start with base config return { ...BASE_PLAN_AGENT_CONFIG, + // When explore agent is enabled, allow the Explore subagent + ...(isExploreEnabled ? { agents: ['Explore'] } : {}), tools, handoffs: [startImplementationHandoff, openInEditorHandoff, ...(BASE_PLAN_AGENT_CONFIG.handoffs ?? [])], - body: PlanAgentProvider.buildAgentBody(), + body: PlanAgentProvider.buildAgentBody(isExploreEnabled, isSearchSubagentEnabled), ...(modelOverride ? { model: modelOverride } : {}), }; } diff --git a/extensions/copilot/src/extension/agents/vscode-node/test/planAgentProvider.spec.ts b/extensions/copilot/src/extension/agents/vscode-node/test/planAgentProvider.spec.ts index 1eaa729888619..278190d0fcf90 100644 --- a/extensions/copilot/src/extension/agents/vscode-node/test/planAgentProvider.spec.ts +++ b/extensions/copilot/src/extension/agents/vscode-node/test/planAgentProvider.spec.ts @@ -362,6 +362,70 @@ suite('PlanAgentProvider', () => { assert.equal(eventFired, true); }); + + test('fires onDidChangeCustomAgents when SearchSubagentToolEnabled setting changes', async () => { + const provider = createProvider(); + + let eventFired = false; + provider.onDidChangeCustomAgents(() => { + eventFired = true; + }); + + await mockConfigurationService.setConfig(ConfigKey.Advanced.SearchSubagentToolEnabled, true); + + assert.equal(eventFired, true); + }); + + test('buildAgentBody uses Explore discovery when explore is enabled', () => { + const body = PlanAgentProvider.buildAgentBody(true, true); + assert.ok(body.includes('Run the *Explore* subagent')); + assert.ok(!body.includes('#tool:searchSubagent')); + }); + + test('buildAgentBody uses search subagent discovery when explore is disabled but search is enabled', () => { + const body = PlanAgentProvider.buildAgentBody(false, true); + assert.ok(body.includes('#tool:searchSubagent')); + assert.ok(!body.includes('Run the *Explore* subagent')); + }); + + test('buildAgentBody uses generic discovery when both explore and search are disabled', () => { + const body = PlanAgentProvider.buildAgentBody(false, false); + assert.ok(body.includes('Search the codebase to gather context')); + assert.ok(!body.includes('Run the *Explore* subagent')); + assert.ok(!body.includes('#tool:searchSubagent')); + }); + + test('excludes agent tool and Explore subagent when explore is disabled', async () => { + await mockConfigurationService.setConfig(ConfigKey.ExploreAgentEnabled, false); + + const provider = createProvider(); + const agents = await provider.provideCustomAgents({}, {} as any); + const content = await getAgentContent(agents[0]); + + // Should not have the 'agent' tool + const toolsMatch = content.match(/tools: \[([^\]]+)\]/); + assert.ok(toolsMatch); + assert.ok(!toolsMatch[1].includes('\'agent\''), 'Should not include agent tool when explore is disabled'); + + // Should not have agents field + assert.ok(!content.includes('agents:'), 'Should not include agents field when explore is disabled'); + }); + + test('includes agent tool and Explore subagent when explore is enabled', async () => { + await mockConfigurationService.setConfig(ConfigKey.ExploreAgentEnabled, true); + + const provider = createProvider(); + const agents = await provider.provideCustomAgents({}, {} as any); + const content = await getAgentContent(agents[0]); + + // Should have the 'agent' tool + const toolsMatch = content.match(/tools: \[([^\]]+)\]/); + assert.ok(toolsMatch); + assert.ok(toolsMatch[1].includes('\'agent\''), 'Should include agent tool when explore is enabled'); + + // Should have agents field with Explore + assert.ok(content.includes('agents:'), 'Should include agents field when explore is enabled'); + }); }); suite('buildAgentMarkdown', () => { diff --git a/extensions/copilot/src/extension/byok/common/byokProvider.ts b/extensions/copilot/src/extension/byok/common/byokProvider.ts index 54237bd90e507..7bfe1c0722894 100644 --- a/extensions/copilot/src/extension/byok/common/byokProvider.ts +++ b/extensions/copilot/src/extension/byok/common/byokProvider.ts @@ -143,7 +143,11 @@ export function byokKnownModelToAPIInfo(providerName: string, id: string, capabi version: '1.0.0', maxOutputTokens: capabilities.maxOutputTokens, maxInputTokens: capabilities.maxInputTokens, - detail: providerName, + // `detail` is intentionally omitted: when this model is resolved + // via a configured provider group, `LanguageModelsService` will + // fall back to the group name so multiple instances of the same + // vendor (e.g. multiple Ollama servers) are distinguishable in + // the model picker. family: id, tooltip: `${capabilities.name} is contributed via the ${providerName} provider.`, multiplierNumeric: 0, diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/common/pendingRequestContext.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/common/pendingRequestContext.ts new file mode 100644 index 0000000000000..fb5479937206d --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/common/pendingRequestContext.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Attachment, SendOptions } from '@github/copilot/sdk'; + +export interface ICopilotCLIPendingRequestContext { + readonly prompt: string; + readonly attachments: Attachment[]; + readonly source?: SendOptions['source']; +} + +const pendingRequestContextBySessionId = new Map(); + +export function setPendingCopilotCLIRequestContext(sessionId: string, context: ICopilotCLIPendingRequestContext): void { + pendingRequestContextBySessionId.set(sessionId, context); +} + +export function takePendingCopilotCLIRequestContext(sessionId: string): ICopilotCLIPendingRequestContext | undefined { + const context = pendingRequestContextBySessionId.get(sessionId); + if (context) { + pendingRequestContextBySessionId.delete(sessionId); + } + return context; +} + +export function clearPendingCopilotCLIRequestContext(sessionId: string): void { + pendingRequestContextBySessionId.delete(sessionId); +} diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 51ec6f29bcdbe..b7decb3b93fed 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -34,6 +34,7 @@ import { IChatSessionMetadataStore } from '../../common/chatSessionMetadataStore import { ExternalEditTracker } from '../../common/externalEditTracker'; import { getWorkingDirectory, isIsolationEnabled, IWorkspaceInfo } from '../../common/workspaceInfo'; import { enrichToolInvocationWithSubagentMetadata, isCopilotCliEditToolCall, isCopilotCLIToolThatCouldRequirePermissions, isTodoRelatedSqlQuery, processToolExecutionComplete, processToolExecutionStart, ToolCall, updateTodoListFromSqlItems, clearTodoList } from '../common/copilotCLITools'; +import { clearPendingCopilotCLIRequestContext, setPendingCopilotCLIRequestContext } from '../common/pendingRequestContext'; import { getCopilotCLISessionDir } from './cliHelpers'; import { SessionIdForCLI } from '../common/utils'; import type { CopilotCliBridgeSpanProcessor } from './copilotCliBridgeSpanProcessor'; @@ -68,6 +69,9 @@ interface McSharedState { mcFlushInterval: ReturnType | undefined; mcPollInterval: ReturnType | undefined; mcLastEventId: string | null; + mcLastSubmitAttemptTimeMs: number; + mcProcessedCommandIds: Set; + mcPendingCommandCompletionIds?: Set; /** Reference to the SDK session for steering from the command poller. */ mcSdkSession: Session; /** Dispose function for the persistent on('*') listener for MC events. */ @@ -77,6 +81,8 @@ interface McSharedState { } const mcStateBySessionId = new Map(); +const MISSION_CONTROL_KEEPALIVE_INTERVAL_MS = 10_000; + interface McPermissionResponseCommandData { readonly promptId?: string; readonly approved?: boolean; @@ -90,7 +96,6 @@ const skippedMissionControlEventTypes = new Set([ 'session.error', 'session.usage_info', 'assistant.usage', - 'session.title_changed', 'pending_messages.modified', 'session.mcp_server_status_changed', 'session.mcp_servers_loaded', @@ -116,14 +121,54 @@ function shouldForwardMissionControlEvent(event: { type?: string; data?: unknown return true; } +function getMissionControlCommandIdFromEvent(event: { type?: string; data?: unknown }): string | undefined { + if (event.type !== 'user.message') { + return undefined; + } + + const source = typeof event.data === 'object' && event.data !== null && 'source' in event.data + ? event.data.source + : undefined; + return typeof source === 'string' && source.startsWith('command-') + ? source.slice('command-'.length) + : undefined; +} + +function getMissionControlSessionTitleFromEvent(event: { type?: string; data?: unknown }): string | undefined { + if (event.type !== 'session.title_changed') { + return undefined; + } + + const title = typeof event.data === 'object' && event.data !== null && 'title' in event.data + ? event.data.title + : undefined; + return typeof title === 'string' && title.trim().length > 0 ? title : undefined; +} + +function getMissionControlPendingCommandCompletionIds(state: McSharedState): Set { + state.mcPendingCommandCompletionIds ??= new Set(); + return state.mcPendingCommandCompletionIds; +} + +function maybeAcknowledgeMissionControlCommandFromEvent(state: McSharedState, event: { type?: string; data?: unknown }): void { + const commandId = getMissionControlCommandIdFromEvent(event); + if (!commandId) { + return; + } + + if (getMissionControlPendingCommandCompletionIds(state).delete(commandId)) { + state.mcCompletedCommandIds.push(commandId); + } +} + export { builtinSlashCommands as builtinSlashSCommands } from '../../common/builtinSlashCommands'; /** * Either a free-form prompt **or** a known command. */ export type CopilotCLISessionInput = - | { readonly prompt: string } - | { readonly prompt?: string; readonly command: CopilotCLICommand }; + | { readonly prompt: string; readonly source?: SendOptions['source'] } + | { readonly prompt?: string; readonly command: CopilotCLICommand; readonly source?: SendOptions['source'] }; function getPromptLabel(input: CopilotCLISessionInput): string { if ('command' in input) { @@ -778,7 +823,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes disposables.add(toDisposable(this._sdkSession.on('session.error', (event) => { flushPendingInvocationMessages(); this.logService.error(`[CopilotCLISession]CopilotCLI error: (${event.data.errorType}), ${event.data.message}`); - this._stream?.markdown(`\n\nError: (${event.data.errorType}) ${event.data.message}`); + this._stream?.markdown(l10n.t('\n\nError: ({0}) {1}', event.data.errorType, event.data.message)); const errorMarkdown = [`# Error Details`, `Type: ${event.data.errorType}`, `Message: ${event.data.message}`, `## Stack`, event.data.stack || ''].join('\n'); this._requestLogger.addEntry({ @@ -866,7 +911,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes this._status = ChatSessionStatus.Failed; this._statusChange.fire(this._status); this.logService.error(`[CopilotCLISession] Invoking session (error) ${this.sessionId}`, error); - this._stream?.markdown(`\n\nError: ${error instanceof Error ? error.message : String(error)}`); + this._stream?.markdown(l10n.t('\n\nError: {0}', error instanceof Error ? error.message : String(error))); invokeAgentSpan.setStatus(SpanStatusCode.ERROR, error instanceof Error ? error.message : String(error)); if (error instanceof Error) { @@ -982,6 +1027,9 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes if (steering) { sendOptions.mode = 'immediate'; } + if (input.source) { + sendOptions.source = input.source; + } await this._sdkSession.send(sendOptions); } } @@ -1092,6 +1140,9 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes mcFlushInterval: undefined, mcPollInterval: undefined, mcLastEventId: null, + mcLastSubmitAttemptTimeMs: Date.now(), + mcProcessedCommandIds: new Set(), + mcPendingCommandCompletionIds: new Set(), mcSdkSession: this._sdkSession, mcEventListenerDispose: undefined, mcSessionResource: SessionIdForCLI.getResource(this.sessionId), @@ -1122,6 +1173,13 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes remoteSteerable: true, })); + const sessionTitle = await this._getMissionControlSessionTitle(); + if (sessionTitle) { + sharedState.mcEventBuffer.push(this._createMcEvent('session.title_changed', { + title: sessionTitle, + }, true)); + } + // Step 7b: Replay existing conversation history so the MC web UI // shows all messages that occurred before /remote was invoked. // Only replay conversation-content events — skip session lifecycle @@ -1155,15 +1213,21 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes // Use the static helper instead of this._bufferMcEvent to avoid // relying on the instance that started MC (it may be stale). const eventType = (event as { type?: string }).type ?? 'unknown'; - if (!shouldForwardMissionControlEvent(event as { type?: string; data?: unknown })) { + const e = event as { type?: string; data?: unknown; id?: string; timestamp?: string; parentId?: string | null; ephemeral?: boolean }; + if (!shouldForwardMissionControlEvent(e)) { return; } - const e = event as { type?: string; data?: unknown; id?: string; timestamp?: string; parentId?: string | null }; + const updatedTitle = getMissionControlSessionTitleFromEvent(e); + if (updatedTitle) { + this._title = updatedTitle; + } + maybeAcknowledgeMissionControlCommandFromEvent(state, e); if (e.id && e.timestamp) { state.mcEventBuffer.push({ id: e.id, timestamp: e.timestamp, parentId: e.parentId ?? state.mcLastEventId ?? null, + ephemeral: e.ephemeral, type: eventType, data: (e.data ?? {}) as Record, }); @@ -1211,12 +1275,13 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes } /** - * Tear down an active Mission Control session. + * Disable remote control for an active Mission Control session. */ private async _teardownRemoteControl(): Promise { - // Stop exporter and poller - this._stopMcEventExporter(); + // Stop local scheduling first so no more commands or periodic flushes race + // with the final disabled-state transition we send to Mission Control. this._stopMcCommandPoller(); + this._stopMcEventExporter(false); const state = this._mcState; if (!state) { @@ -1234,11 +1299,14 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes } state.mcPendingPermissionRequests.clear(); - const mcSessionId = state.mcSessionId; - mcStateBySessionId.delete(this.sessionId); - this.logService.info(`[CopilotCLISession] Tearing down MC session ${mcSessionId}`); + state.mcEventBuffer.push(this._createMcEvent('session.remote_steerable_changed', { + remoteSteerable: false, + })); + state.mcEventBuffer.push(this._createMcEvent('session.idle', {})); + await this._flushMcEvents(); - await this._missionControlApiClient.deleteSession(mcSessionId); + mcStateBySessionId.delete(this.sessionId); + this.logService.info(`[CopilotCLISession] Disabled MC remote control for session ${state.mcSessionId}`); } /** @@ -1285,13 +1353,13 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes } /** Stop the MC event exporter. */ - private _stopMcEventExporter(): void { + private _stopMcEventExporter(clearBuffer = true): void { const state = this._mcState; if (state?.mcFlushInterval) { clearInterval(state.mcFlushInterval); state.mcFlushInterval = undefined; } - if (state) { + if (state && clearBuffer) { state.mcEventBuffer.length = 0; } } @@ -1300,7 +1368,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes * Buffer an SDK event for Mission Control. Called from the per-send * on('*') handler so that events are captured on every turn. */ - private _bufferMcEvent(event: { type?: string; data?: unknown; id?: string; timestamp?: string; parentId?: string | null }): void { + private _bufferMcEvent(event: { type?: string; data?: unknown; id?: string; timestamp?: string; parentId?: string | null; ephemeral?: boolean }): void { const state = this._mcState; const eventType = event.type ?? 'unknown'; if (!state) { @@ -1309,7 +1377,12 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes if (!shouldForwardMissionControlEvent(event)) { return; } - this.logService.info(`[CopilotCLISession] MC buffered event: ${eventType}`); + const updatedTitle = getMissionControlSessionTitleFromEvent(event); + if (updatedTitle) { + this._title = updatedTitle; + } + maybeAcknowledgeMissionControlCommandFromEvent(state, event); + this.logService.trace(`[CopilotCLISession] MC buffered event: ${eventType}`); // If the SDK event already has a UUID id, pass it through directly // to preserve the event identity chain. Otherwise create a new event. @@ -1318,6 +1391,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes id: event.id, timestamp: event.timestamp, parentId: event.parentId ?? state.mcLastEventId ?? null, + ephemeral: event.ephemeral, type: eventType, data: (event.data ?? {}) as Record, }; @@ -1329,13 +1403,14 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes } /** Create an MC event with a UUID v4 ID and parentId chain. */ - private _createMcEvent(type: string, data: Record): McEvent { + private _createMcEvent(type: string, data: Record, ephemeral?: boolean): McEvent { const state = this._mcState; const id = crypto.randomUUID(); const event: McEvent = { id, timestamp: new Date().toISOString(), parentId: state?.mcLastEventId ?? null, + ephemeral, type, data, }; @@ -1345,6 +1420,41 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes return event; } + private async _getMissionControlSessionTitle(): Promise { + const liveTitle = this._title?.trim(); + if (liveTitle) { + return liveTitle; + } + + const sessionEvents = this._sdkSession.getEvents() as readonly { type?: string; data?: unknown }[]; + for (let i = sessionEvents.length - 1; i >= 0; i--) { + const eventTitle = getMissionControlSessionTitleFromEvent(sessionEvents[i]); + if (eventTitle) { + return eventTitle; + } + } + + const customTitle = (await this._chatSessionMetadataStore.getCustomTitle(this.sessionId))?.trim(); + if (customTitle) { + return customTitle; + } + + for (const event of sessionEvents) { + if (event.type !== 'user.message') { + continue; + } + const content = typeof event.data === 'object' && event.data !== null && 'content' in event.data + ? event.data.content + : undefined; + if (typeof content === 'string' && content.trim().length > 0) { + return content.trim(); + } + } + + const pendingTitle = this._pendingPrompt?.trim(); + return pendingTitle || undefined; + } + private _waitForMcPermissionResponse( state: McSharedState, permissionRequest: PermissionRequest, @@ -1376,15 +1486,24 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes */ private async _flushMcEvents(): Promise { const state = this._mcState; - if (!state || !state.mcSessionId || state.mcEventBuffer.length === 0) { + if (!state || !state.mcSessionId) { return; } - const events = state.mcEventBuffer.splice(0, 500); const completedCommandIds = state.mcCompletedCommandIds.splice(0); + const shouldSendKeepAlive = + state.mcEventBuffer.length === 0 && + completedCommandIds.length === 0 && + Date.now() - state.mcLastSubmitAttemptTimeMs >= MISSION_CONTROL_KEEPALIVE_INTERVAL_MS; + if (state.mcEventBuffer.length === 0 && completedCommandIds.length === 0 && !shouldSendKeepAlive) { + return; + } + + state.mcLastSubmitAttemptTimeMs = Date.now(); + const events = state.mcEventBuffer.splice(0, 500); const eventTypes = events.map(e => e.type).join(', '); - this.logService.info(`[CopilotCLISession] Flushing ${events.length} MC event(s): [${eventTypes}]`); + this.logService.info(`[CopilotCLISession] Flushing ${events.length} MC event(s): [${eventTypes}]${completedCommandIds.length ? ` with ${completedCommandIds.length} completed command(s)` : ''}${shouldSendKeepAlive ? ' (keepalive)' : ''}`); try { const success = await this._missionControlApiClient.submitEvents(state.mcSessionId, events, completedCommandIds); @@ -1393,10 +1512,12 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes if (state.mcEventBuffer.length < 2000) { state.mcEventBuffer.unshift(...events); } + state.mcCompletedCommandIds.unshift(...completedCommandIds); } else { this.logService.info(`[CopilotCLISession] MC event flush OK: ${events.length} event(s)`); } } catch (err) { + state.mcCompletedCommandIds.unshift(...completedCommandIds); this.logService.warn(`[CopilotCLISession] MC event submission error: ${err}`); } } @@ -1423,7 +1544,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes if (!currentState || !currentState.mcSessionId) { return; } - CopilotCLISession._pollMcCommandsStatic(currentState, missionControlApiClient, logService).catch(err => { + CopilotCLISession._pollMcCommandsStatic(sessionId, currentState, missionControlApiClient, logService).catch(err => { logService.warn(`[CopilotCLISession] MC command poll failed: ${err}`); }); }, 3000); @@ -1444,14 +1565,21 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes * Poll Mission Control for pending commands and process them. * Static method to avoid capturing a stale `this` reference. */ - private static async _pollMcCommandsStatic(state: McSharedState, missionControlApiClient: MissionControlApiClient, logService: { info(msg: string): void; warn(msg: string): void }): Promise { + private static async _pollMcCommandsStatic(sessionId: string, state: McSharedState, missionControlApiClient: MissionControlApiClient, logService: { info(msg: string): void; warn(msg: string): void }): Promise { try { const commands = await missionControlApiClient.getPendingCommands(state.mcSessionId); + const pendingCommandIds = new Set(commands.map(cmd => cmd.id)); + for (const processedId of state.mcProcessedCommandIds) { + if (!pendingCommandIds.has(processedId)) { + state.mcProcessedCommandIds.delete(processedId); + } + } for (const cmd of commands) { - if (cmd.state !== 'in_progress') { + if (cmd.state !== 'in_progress' || state.mcProcessedCommandIds.has(cmd.id)) { continue; } + state.mcProcessedCommandIds.add(cmd.id); logService.info(`[CopilotCLISession] Processing MC command: ${cmd.type ?? 'user_message'} (${cmd.id})`); switch (cmd.type) { @@ -1478,6 +1606,12 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes // Route steering messages through the VS Code chat UI so // they appear in the chat panel with proper rendering. const vsCodeApi = require('vscode') as typeof import('vscode'); + getMissionControlPendingCommandCompletionIds(state).add(cmd.id); + setPendingCopilotCLIRequestContext(sessionId, { + prompt: cmd.content, + attachments: [], + source: `command-${cmd.id}`, + }); vsCodeApi.commands.executeCommand( 'workbench.action.chat.openSessionWithPrompt.copilotcli', { @@ -1485,14 +1619,18 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes prompt: cmd.content, } ).then(undefined, err => { + clearPendingCopilotCLIRequestContext(sessionId); + getMissionControlPendingCommandCompletionIds(state).delete(cmd.id); + state.mcCompletedCommandIds.push(cmd.id); logService.warn(`[CopilotCLISession] MC steering send failed: ${err}`); }); break; } } - // Mark command as processed - state.mcCompletedCommandIds.push(cmd.id); + if (cmd.type !== 'user_message' && cmd.type !== undefined) { + state.mcCompletedCommandIds.push(cmd.id); + } } } catch { // Silently ignore polling errors diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/missionControlApiClient.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/missionControlApiClient.ts index 6595116521d36..827a0c13c1313 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/missionControlApiClient.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/missionControlApiClient.ts @@ -20,6 +20,7 @@ export interface McEvent { id: string; timestamp: string; parentId: string | null; + ephemeral?: boolean; type: string; data: Record; } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts index 72ed404c49281..cae2cbb89e22c 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts @@ -106,7 +106,7 @@ class MockSdkSession { // placeholder for user input responses } - public lastSendOptions: { prompt: string; mode?: string } | undefined; + public lastSendOptions: { prompt: string; mode?: string; source?: string } | undefined; public currentMode: string | undefined; async send(options: { prompt: string; mode?: string }) { @@ -696,6 +696,8 @@ describe('CopilotCLISession', () => { mcFlushInterval: undefined, mcPollInterval: undefined, mcLastEventId: null, + mcLastSubmitAttemptTimeMs: Date.now(), + mcProcessedCommandIds: new Set(), mcSdkSession: sdkSession as unknown as Session, mcEventListenerDispose: undefined, mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri, @@ -713,6 +715,7 @@ describe('CopilotCLISession', () => { await new Promise(r => setTimeout(r, 0)); await (CopilotCLISession as any)._pollMcCommandsStatic( + session.sessionId, remoteState, { getPendingCommands: async () => [{ @@ -762,6 +765,8 @@ describe('CopilotCLISession', () => { mcFlushInterval: undefined, mcPollInterval: undefined, mcLastEventId: null, + mcLastSubmitAttemptTimeMs: Date.now(), + mcProcessedCommandIds: new Set(), mcSdkSession: sdkSession as unknown as Session, mcEventListenerDispose: undefined, mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri, @@ -795,6 +800,8 @@ describe('CopilotCLISession', () => { mcFlushInterval: undefined, mcPollInterval: undefined, mcLastEventId: null, + mcLastSubmitAttemptTimeMs: Date.now(), + mcProcessedCommandIds: new Set(), mcSdkSession: sdkSession as unknown as Session, mcEventListenerDispose: undefined, mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri, @@ -807,6 +814,49 @@ describe('CopilotCLISession', () => { expect((remoteState.mcEventBuffer[0] as { type: string }).type).toBe('session.idle'); }); + it('forwards session.title_changed to Mission Control as an ephemeral event', async () => { + const session = await createSession(); + const remoteState = { + mcSessionId: 'mc-session', + mcEventBuffer: [], + mcCompletedCommandIds: [], + mcPendingPermissionRequests: new Map(), + mcFlushInterval: undefined, + mcPollInterval: undefined, + mcLastEventId: null, + mcLastSubmitAttemptTimeMs: Date.now(), + mcProcessedCommandIds: new Set(), + mcSdkSession: sdkSession as unknown as Session, + mcEventListenerDispose: undefined, + mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri, + }; + Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true }); + + (session as any)._bufferMcEvent({ + type: 'session.title_changed', + id: 'title-change-1', + timestamp: '2026-01-01T00:00:00.000Z', + parentId: 'visible-root-message', + ephemeral: true, + data: { title: 'Remote Session Title' }, + }); + + expect(remoteState.mcEventBuffer).toHaveLength(1); + expect((remoteState.mcEventBuffer[0] as { type: string; ephemeral?: true }).type).toBe('session.title_changed'); + expect((remoteState.mcEventBuffer[0] as { ephemeral?: true }).ephemeral).toBe(true); + expect((remoteState.mcEventBuffer[0] as { data: { title: string } }).data.title).toBe('Remote Session Title'); + }); + + it('prefers existing session history over the current /remote prompt when deriving the Mission Control title', async () => { + const session = await createSession(); + vi.spyOn(sdkSession, 'getEvents').mockReturnValue([ + { type: 'user.message', data: { content: 'hey' } }, + ] as any); + (session as any)._pendingPrompt = '/remote'; + + await expect((session as any)._getMissionControlSessionTitle()).resolves.toBe('hey'); + }); + it('does not forward report_intent tool events to Mission Control', async () => { const session = await createSession(); const remoteState = { @@ -817,6 +867,8 @@ describe('CopilotCLISession', () => { mcFlushInterval: undefined, mcPollInterval: undefined, mcLastEventId: null, + mcLastSubmitAttemptTimeMs: Date.now(), + mcProcessedCommandIds: new Set(), mcSdkSession: sdkSession as unknown as Session, mcEventListenerDispose: undefined, mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri, @@ -841,6 +893,136 @@ describe('CopilotCLISession', () => { expect((remoteState.mcEventBuffer[0] as { data: { toolName: string } }).data.toolName).toBe('bash'); }); + it('forwards command-sourced user messages and acknowledges the command with the echoed turn', async () => { + const session = await createSession(); + const remoteState = { + mcSessionId: 'mc-session', + mcEventBuffer: [], + mcCompletedCommandIds: [], + mcPendingPermissionRequests: new Map(), + mcFlushInterval: undefined, + mcPollInterval: undefined, + mcLastEventId: null, + mcLastSubmitAttemptTimeMs: Date.now(), + mcProcessedCommandIds: new Set(), + mcPendingCommandCompletionIds: new Set(['mc-command-1']), + mcSdkSession: sdkSession as unknown as Session, + mcEventListenerDispose: undefined, + mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri, + }; + Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true }); + + (session as any)._bufferMcEvent({ + type: 'user.message', + id: 'remote-command-message', + timestamp: '2026-01-01T00:00:00.000Z', + parentId: 'visible-root-message', + data: { content: 'hey', source: 'command-mc-command-1' }, + }); + expect(remoteState.mcCompletedCommandIds).toEqual(['mc-command-1']); + + (session as any)._bufferMcEvent({ + type: 'assistant.message', + id: 'assistant-reply', + timestamp: '2026-01-01T00:00:01.000Z', + parentId: 'remote-command-message', + data: { content: 'Hello! How can I help you today?' }, + }); + + expect(remoteState.mcEventBuffer).toHaveLength(2); + expect((remoteState.mcEventBuffer[0] as { type: string }).type).toBe('user.message'); + expect((remoteState.mcEventBuffer[0] as { data: { content: string } }).data.content).toBe('hey'); + expect((remoteState.mcEventBuffer[1] as { type: string; parentId: string | null }).type).toBe('assistant.message'); + expect((remoteState.mcEventBuffer[1] as { parentId: string | null }).parentId).toBe('remote-command-message'); + }); + + it('forwards remote command source to the SDK send options', async () => { + const session = await createSession(); + const stream = new MockChatResponseStream(); + session.attachStream(stream); + + await session.handleRequest( + { id: '', toolInvocationToken: undefined as never }, + { prompt: 'hey', source: 'command-mc-command-1' }, + [], + undefined, + authInfo, + CancellationToken.None + ); + + expect(sdkSession.lastSendOptions?.source).toBe('command-mc-command-1'); + }); + + it('flushes completed Mission Control command ids even when there are no buffered events', async () => { + const session = await createSession(); + const submitEvents = vi.fn(async () => true); + const remoteState = { + mcSessionId: 'mc-session', + mcEventBuffer: [], + mcCompletedCommandIds: ['mc-command-1'], + mcPendingPermissionRequests: new Map(), + mcFlushInterval: undefined, + mcPollInterval: undefined, + mcLastEventId: null, + mcLastSubmitAttemptTimeMs: Date.now(), + mcProcessedCommandIds: new Set(), + mcSdkSession: sdkSession as unknown as Session, + mcEventListenerDispose: undefined, + mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri, + }; + Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true }); + Object.defineProperty(session, '_missionControlApiClient', { + value: { submitEvents }, + configurable: true, + }); + + await (session as any)._flushMcEvents(); + + expect(submitEvents).toHaveBeenCalledWith('mc-session', [], ['mc-command-1']); + expect(remoteState.mcCompletedCommandIds).toEqual([]); + }); + + it('announces remote control disabled to Mission Control before detaching locally', async () => { + const session = await createSession(); + const submitEvents = vi.fn(async () => true); + const deleteSession = vi.fn(async () => undefined); + const pendingRequest = vi.fn(); + const mcEventListenerDispose = vi.fn(); + const remoteState = { + mcSessionId: 'mc-session', + mcEventBuffer: [], + mcCompletedCommandIds: [], + mcPendingPermissionRequests: new Map([['prompt-1', { resolve: pendingRequest }]]), + mcFlushInterval: undefined, + mcPollInterval: undefined, + mcLastEventId: null, + mcLastSubmitAttemptTimeMs: Date.now(), + mcProcessedCommandIds: new Set(), + mcSdkSession: sdkSession as unknown as Session, + mcEventListenerDispose, + mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri, + }; + Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true }); + Object.defineProperty(session, '_missionControlApiClient', { + value: { submitEvents, deleteSession }, + configurable: true, + }); + + await (session as any)._teardownRemoteControl(); + + expect(pendingRequest).toHaveBeenCalledWith({ kind: 'denied-interactively-by-user' }); + expect(mcEventListenerDispose).toHaveBeenCalledTimes(1); + expect(submitEvents).toHaveBeenCalledWith( + 'mc-session', + expect.arrayContaining([ + expect.objectContaining({ type: 'session.remote_steerable_changed', data: { remoteSteerable: false } }), + expect.objectContaining({ type: 'session.idle', data: {} }), + ]), + [], + ); + expect(deleteSession).not.toHaveBeenCalled(); + }); + it('immediately pushes invocation messages for non-permission-requiring tools like MCP', async () => { let resolveSend: () => void; sdkSession.send = async () => new Promise(r => { resolveSend = r; }); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index c54f4369df5cc..3206284c51e4d 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { Attachment, SessionOptions, SweCustomAgent } from '@github/copilot/sdk'; +import type { Attachment, SendOptions, SessionOptions, SweCustomAgent } from '@github/copilot/sdk'; import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; import { ChatExtendedRequestHandler, ChatRequestTurn2, Uri } from 'vscode'; @@ -35,6 +35,7 @@ import { IChatFolderMruService, IFolderRepositoryManager, IsolationMode } from ' import { getWorkingDirectory, IWorkspaceInfo } from '../common/workspaceInfo'; import { ICustomSessionTitleService } from '../copilotcli/common/customSessionTitleService'; import { IChatDelegationSummaryService } from '../copilotcli/common/delegationSummaryService'; +import { clearPendingCopilotCLIRequestContext, setPendingCopilotCLIRequestContext, takePendingCopilotCLIRequestContext } from '../copilotcli/common/pendingRequestContext'; import { SessionIdForCLI } from '../copilotcli/common/utils'; import { getCopilotCLISessionDir } from '../copilotcli/node/cliHelpers'; import { ICopilotCLISDK } from '../copilotcli/node/copilotCli'; @@ -672,8 +673,6 @@ export class CopilotCLIChatSessionParticipant extends Disposable { return this.handleRequest.bind(this); } - private readonly contextForRequest = new Map(); - /** * Outer request handler that supports *yielding* for session steering. * @@ -755,19 +754,19 @@ export class CopilotCLIChatSessionParticipant extends Disposable { * Resolve the input and attachments for the SDK session based on request type. * * The VS Code chat API creates the session before firing the request handler, - * so delegated requests pre-resolve and cache prompt/attachments in `contextForRequest`. + * so delegated or remotely-steered requests pre-resolve and cache their prompt metadata + * before the handler runs. */ private async resolveInput( request: vscode.ChatRequest, session: ICopilotCLISession, isNewSession: boolean, token: vscode.CancellationToken, - ): Promise<{ input: { prompt: string; command?: CopilotCLICommand }; attachments: Attachment[] }> { - const contextForRequest = this.contextForRequest.get(session.sessionId); - this.contextForRequest.delete(session.sessionId); + ): Promise<{ input: { prompt: string; command?: CopilotCLICommand; source?: SendOptions['source'] }; attachments: Attachment[] }> { + const contextForRequest = takePendingCopilotCLIRequestContext(session.sessionId); if (contextForRequest) { - return { input: { prompt: contextForRequest.prompt }, attachments: contextForRequest.attachments }; + return { input: { prompt: contextForRequest.prompt, source: contextForRequest.source }, attachments: contextForRequest.attachments }; } if (request.command && !request.prompt && !isNewSession) { @@ -956,11 +955,14 @@ export class CopilotCLIChatSessionParticipant extends Disposable { } } - this.contextForRequest.set(session.object.sessionId, { prompt, attachments }); + setPendingCopilotCLIRequestContext(session.object.sessionId, { prompt, attachments }); void vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', { resource: SessionIdForCLI.getResource(session.object.sessionId), prompt: userPrompt || request.prompt, attachedContext: references.map(ref => convertReferenceToVariable(ref, attachments)) + }).then(undefined, error => { + clearPendingCopilotCLIRequestContext(session.object.sessionId); + this.logService.error(error, '[CopilotCLIChatSessionContentProvider] Failed to open Copilot CLI session'); }); stream.markdown(l10n.t('A Copilot CLI session has begun working on your request. Follow its progress in the sessions list.')); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index 88b9c494e327b..4c7d156c7e8b9 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -1391,6 +1391,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { inputState: { groups: [], sessionResource: undefined, + onDidDispose: Event.None, onDidChange: Event.None } }; diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts index 1dae596a6ca04..8f36f0ed19a24 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts @@ -84,6 +84,7 @@ beforeAll(() => { groups, sessionResource: undefined, onDidChange: emitter.event, + onDidDispose: Event.None, }; // Proxy that fires onDidChange when groups are replaced return new Proxy(state, { @@ -325,7 +326,7 @@ async function runHandlerAndCapture( resource: ClaudeSessionUri.forSessionId(sessionId), label: 'Test Session', }, - inputState: { groups, sessionResource: undefined, onDidChange: Event.None }, + inputState: { groups, sessionResource: undefined, onDidChange: Event.None, onDidDispose: Event.None }, }, } as vscode.ChatContext; @@ -660,7 +661,7 @@ describe('ChatSessionContentProvider', () => { resource: ClaudeSessionUri.forSessionId(sessionId), label: 'Test Session', }, - inputState: { groups: buildInputStateGroups(options), sessionResource: undefined, onDidChange: Event.None }, + inputState: { groups: buildInputStateGroups(options), sessionResource: undefined, onDidChange: Event.None, onDidDispose: Event.None }, }, } as vscode.ChatContext; } @@ -775,6 +776,7 @@ describe('ChatSessionContentProvider', () => { }), sessionResource: undefined, onDidChange: Event.None, + onDidDispose: Event.None, }, }, } as vscode.ChatContext; @@ -845,7 +847,7 @@ describe('ChatSessionContentProvider', () => { resource: ClaudeSessionUri.forSessionId(sessionId), label: 'Test Session', }, - inputState: { groups: buildInputStateGroups(), sessionResource: undefined, onDidChange: Event.None }, + inputState: { groups: buildInputStateGroups(), sessionResource: undefined, onDidChange: Event.None, onDidDispose: Event.None }, }, } as vscode.ChatContext; } @@ -945,7 +947,7 @@ describe('ChatSessionContentProvider', () => { resource: ClaudeSessionUri.forSessionId(sessionId), label: 'Test Session', }, - inputState: { groups: buildInputStateGroups(), sessionResource: undefined, onDidChange: Event.None }, + inputState: { groups: buildInputStateGroups(), sessionResource: undefined, onDidChange: Event.None, onDidDispose: Event.None }, }, } as vscode.ChatContext; } @@ -1163,6 +1165,7 @@ describe('ChatSessionContentProvider', () => { groups: lockedGroups, sessionResource: undefined, onDidChange: Event.None, + onDidDispose: Event.None, }; // sanity check expect(initialGroup.items.map(i => i.id)).toEqual([folderA.fsPath, folderB.fsPath]); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts index 270be0dde4965..332ea490a6982 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts @@ -106,6 +106,7 @@ function makeRef(name: string, type: number = 0 /* Head */): { name: string; typ function createMockChatSessionInputState(groups: readonly vscode.ChatSessionProviderOptionGroup[]): vscode.ChatSessionInputState { return { + onDidDispose: Event.None, onDidChange: Event.None, groups, sessionResource: undefined diff --git a/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts b/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts index 404adb2c5cb10..c7d3d2de9605e 100644 --- a/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts +++ b/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts @@ -16,14 +16,6 @@ export type SessionIndexingLevel = 'local' | 'user' | 'repo_and_user'; /** * Manages user preferences for session indexing via VS Code settings. - * - * Two settings control behavior: - * - `chat.sessionSearch.localIndex.enabled` (team-internal, ExP) — enables local - * SQLite tracking and /chronicle commands - * - `chat.sessionSearch.cloudSync.enabled` — enables - * cloud upload to cloud - * - `chat.sessionSearch.cloudSync.excludeRepositories` — repo patterns - * to exclude from cloud sync */ export class SessionIndexingPreference { diff --git a/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts b/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts index e5b07ca575163..82568f03d9dd0 100644 --- a/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts +++ b/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts @@ -13,7 +13,7 @@ function createMockConfigService(opts: { } = {}) { const configs: Record = {}; // Map by fullyQualifiedId - configs['github.copilot.chat.advanced.sessionSearch.localIndex.enabled'] = opts.localIndexEnabled ?? false; + configs['github.copilot.chat.localIndex.enabled'] = opts.localIndexEnabled ?? false; configs['github.copilot.chat.advanced.sessionSearch.cloudSync.enabled'] = opts.cloudSyncEnabled ?? false; configs['github.copilot.chat.advanced.sessionSearch.cloudSync.excludeRepositories'] = opts.excludeRepositories ?? []; diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts b/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts index c469e4e8d0fcd..35a51719c5b21 100644 --- a/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts +++ b/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts @@ -120,7 +120,7 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr // Only set up span listener when both local index and cloud sync are enabled. // Uses autorun to react if settings change at runtime. - const localEnabled = this._configService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.SessionSearchLocalIndexEnabled, this._expService); + const localEnabled = this._configService.getExperimentBasedConfigObservable(ConfigKey.LocalIndexEnabled, this._expService); const cloudEnabled = this._configService.getConfigObservable(ConfigKey.TeamInternal.SessionSearchCloudSyncEnabled); const spanListenerStore = this._register(new DisposableStore()); this._register(autorun(reader => { diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts b/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts index 993f0d134d36c..43a432f2002a6 100644 --- a/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts +++ b/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts @@ -80,7 +80,7 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib // Only set up span listener and flush timer when the feature is enabled. // Uses autorun to react if the setting changes at runtime. - const featureEnabled = this._configService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.SessionSearchLocalIndexEnabled, this._expService); + const featureEnabled = this._configService.getExperimentBasedConfigObservable(ConfigKey.LocalIndexEnabled, this._expService); const spanListenerStore = this._register(new DisposableStore()); this._register(autorun(reader => { spanListenerStore.clear(); diff --git a/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts b/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts index 876efa5244a90..4b4dacf885518 100644 --- a/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts +++ b/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts @@ -85,7 +85,7 @@ export class ContextKeysContribution extends Disposable { commands.executeCommand('setContext', debugReportFeedbackContextKey, debugReportFeedback.read(reader)); })); - const sessionSearchEnabled = this._configService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.SessionSearchLocalIndexEnabled, this._expService); + const sessionSearchEnabled = this._configService.getExperimentBasedConfigObservable(ConfigKey.LocalIndexEnabled, this._expService); this._register(autorun(reader => { commands.executeCommand('setContext', sessionSearchEnabledContextKey, sessionSearchEnabled.read(reader)); })); diff --git a/extensions/copilot/src/extension/intents/node/chronicleIntent.ts b/extensions/copilot/src/extension/intents/node/chronicleIntent.ts index 05376f288602a..8f43160d47633 100644 --- a/extensions/copilot/src/extension/intents/node/chronicleIntent.ts +++ b/extensions/copilot/src/extension/intents/node/chronicleIntent.ts @@ -49,7 +49,7 @@ export class ChronicleIntent implements IIntent { readonly id = ChronicleIntent.ID; readonly description = l10n.t('Session history tools and insights (standup, tips, improve)'); get locations(): ChatLocation[] { - return this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.SessionSearchLocalIndexEnabled, this._expService) ? [ChatLocation.Panel] : []; + return this._configService.getExperimentBasedConfig(ConfigKey.LocalIndexEnabled, this._expService) ? [ChatLocation.Panel] : []; } readonly commandInfo: IIntentSlashCommandInfo = { @@ -86,7 +86,7 @@ export class ChronicleIntent implements IIntent { location: ChatLocation, chatTelemetry: ChatTelemetryBuilder, ): Promise { - if (!this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.SessionSearchLocalIndexEnabled, this._expService)) { + if (!this._configService.getExperimentBasedConfig(ConfigKey.LocalIndexEnabled, this._expService)) { stream.markdown(l10n.t('Session search is not available yet.')); return {}; } diff --git a/extensions/copilot/src/extension/tools/vscode-node/switchAgentTool.ts b/extensions/copilot/src/extension/tools/vscode-node/switchAgentTool.ts index 9ab54f779b8b1..f0cf084656d1a 100644 --- a/extensions/copilot/src/extension/tools/vscode-node/switchAgentTool.ts +++ b/extensions/copilot/src/extension/tools/vscode-node/switchAgentTool.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; +import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; import { CancellationToken } from '../../../util/vs/base/common/cancellation'; import { LanguageModelTextPart, LanguageModelToolResult, MarkdownString } from '../../../vscodeTypes'; import { PlanAgentProvider } from '../../agents/vscode-node/planAgentProvider'; @@ -18,6 +20,11 @@ export class SwitchAgentTool implements ICopilotTool { public static readonly toolName = ToolName.SwitchAgent; public static readonly nonDeferred = true; + constructor( + @IConfigurationService private readonly configurationService: IConfigurationService, + @IExperimentationService private readonly experimentationService: IExperimentationService, + ) { } + async invoke(options: vscode.LanguageModelToolInvocationOptions, token: CancellationToken): Promise { const { agentName } = options.input; @@ -26,7 +33,9 @@ export class SwitchAgentTool implements ICopilotTool { throw new Error(vscode.l10n.t('Only "Plan" agent is supported')); } - const planAgentBody = PlanAgentProvider.buildAgentBody(); + const exploreEnabled = this.configurationService.getExperimentBasedConfig(ConfigKey.ExploreAgentEnabled, this.experimentationService); + const searchSubagentEnabled = this.configurationService.getExperimentBasedConfig(ConfigKey.Advanced.SearchSubagentToolEnabled, this.experimentationService); + const planAgentBody = PlanAgentProvider.buildAgentBody(exploreEnabled, searchSubagentEnabled); // Execute command to switch agent await vscode.commands.executeCommand('workbench.action.chat.toggleAgentMode', { diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 936bb3a2c16f7..8857f9e44ffde 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -875,8 +875,6 @@ export namespace ConfigKey { export const ResponsesApiWebSocketEnabled = defineTeamInternalSetting('chat.advanced.responsesApi.webSocket.enabled', ConfigType.ExperimentBased, false); export const DebugSimulateWebSocketResponse = defineTeamInternalSetting('chat.advanced.debug.simulateWebSocketResponse', ConfigType.Simple, ''); - /** Enable local session search index — tracks sessions locally and enables chronicle commands.*/ - export const SessionSearchLocalIndexEnabled = defineTeamInternalSetting('chat.advanced.sessionSearch.localIndex.enabled', ConfigType.ExperimentBased, false, vBoolean()); /** Enable cloud sync of session data to cloud. */ export const SessionSearchCloudSyncEnabled = defineTeamInternalSetting('chat.advanced.sessionSearch.cloudSync.enabled', ConfigType.Simple, false, vBoolean()); /** Repository patterns to exclude from cloud sync (exact owner/repo or glob patterns like my-org/*). */ @@ -1030,6 +1028,9 @@ export namespace ConfigKey { export const CopilotMemoryEnabled = defineSetting('chat.copilotMemory.enabled', ConfigType.ExperimentBased, false); export const MemoryToolEnabled = defineSetting('chat.tools.memory.enabled', ConfigType.ExperimentBased, true); export const ViewImageToolEnabled = defineSetting('chat.tools.viewImage.enabled', ConfigType.ExperimentBased, true); + + /** Enable local session search index — tracks sessions locally and enables chronicle commands.*/ + export const LocalIndexEnabled = defineSetting('chat.localIndex.enabled', ConfigType.ExperimentBased, false); } export function getAllConfigKeys(): string[] { diff --git a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts index 24cc6056d0cf3..fc3c6469535bb 100644 --- a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts @@ -101,7 +101,8 @@ export function createMessagesRequestBody(accessor: ServicesAccessor, options: I const experimentationService = accessor.get(IExperimentationService); const toolDeferralService = accessor.get(IToolDeferralService); - const toolSearchEnabled = !!endpoint.supportsToolSearch; + const toolSearchEnabled = !!endpoint.supportsToolSearch + && !!options.requestOptions?.tools?.some(t => t.function.name === CUSTOM_TOOL_SEARCH_NAME); // Split tools into non-deferred and deferred up front so we can build finalTools // with non-deferred first. This ensures the cache_control breakpoint on the last diff --git a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts index 700fb85ab48e2..a42223b0aa5ac 100644 --- a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts @@ -65,7 +65,8 @@ export function createResponsesRequestBody(accessor: ServicesAccessor, options: const toolSearchEnabled = isResponsesApiToolSearchEnabled(endpoint, configService, expService); const isAllowedConversationAgent = options.location === ChatLocation.Agent || options.location === ChatLocation.MessagesProxy; const isSubagent = options.telemetryProperties?.subType?.startsWith('subagent') ?? false; - const shouldDeferTools = toolSearchEnabled && isAllowedConversationAgent && !isSubagent; + const toolSearchInRequest = !!options.requestOptions?.tools?.some(t => t.function.name === CUSTOM_TOOL_SEARCH_NAME); + const shouldDeferTools = toolSearchEnabled && isAllowedConversationAgent && !isSubagent && toolSearchInRequest; const toolDeferralService = shouldDeferTools ? accessor.get(IToolDeferralService) : undefined; type ResponsesFunctionTool = OpenAI.Responses.FunctionTool & OpenAiResponsesFunctionTool; diff --git a/extensions/copilot/src/platform/endpoint/node/test/responsesApiToolSearch.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/responsesApiToolSearch.spec.ts index 3d4dd218aacec..728c106eac42a 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/responsesApiToolSearch.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/responsesApiToolSearch.spec.ts @@ -57,6 +57,7 @@ function createMockOptions(overrides: Partial = {}): { type: 'function', function: { name: 'grep_search', description: 'Search for text', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } }, { type: 'function', function: { name: 'some_mcp_tool', description: 'An MCP tool', parameters: { type: 'object', properties: { input: { type: 'string' } }, required: ['input'] } } }, { type: 'function', function: { name: 'another_deferred_tool', description: 'Another tool', parameters: { type: 'object', properties: {} } } }, + { type: 'function', function: { name: 'tool_search', description: 'Search tools', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } }, ] }, ...overrides, @@ -149,6 +150,35 @@ describe('createResponsesRequestBody tools', () => { expect(tools.every(t => !t.defer_loading)).toBe(true); }); + it('does not defer tools when tool_search is not in the request tool list', () => { + // Repro for https://github.com/microsoft/vscode/issues/311946: a custom agent with + // `tools: ['my-mcp-server/*']` filters out tool_search. Without this gate, every + // MCP tool would be marked deferred and stripped from the request, leaving the + // agent with nothing to call. + const endpoint = createMockEndpoint('gpt-5.4-preview'); + const configService = accessor.get(IConfigurationService) as InMemoryConfigurationService; + configService.setConfig(ConfigKey.ResponsesApiToolSearchEnabled, true); + + const options = createMockOptions({ + requestOptions: { + tools: [ + { type: 'function', function: { name: 'some_mcp_tool', description: 'An MCP tool', parameters: { type: 'object', properties: {} } } }, + { type: 'function', function: { name: 'another_mcp_tool', description: 'Another MCP tool', parameters: { type: 'object', properties: {} } } }, + ] + } + }); + const body = accessor.get(IInstantiationService).invokeFunction( + createResponsesRequestBody, options, endpoint.model, endpoint + ); + + const tools = body.tools as any[]; + // No client tool_search should be added. + expect(tools.find(t => t.type === 'tool_search')).toBeUndefined(); + // All user-listed tools should be sent to the model, not stripped. + expect(tools.find(t => t.name === 'some_mcp_tool')).toBeDefined(); + expect(tools.find(t => t.name === 'another_mcp_tool')).toBeDefined(); + }); + it('always filters tool_search function tool from tools array', () => { const endpoint = createMockEndpoint('gpt-5.4-preview'); const configService = accessor.get(IConfigurationService) as InMemoryConfigurationService; diff --git a/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts b/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts index da00bbb684bd2..798481a0466d5 100644 --- a/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts +++ b/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts @@ -819,3 +819,112 @@ describe('createMessagesRequestBody reasoning effort', () => { expect(body.output_config).toEqual({ effort: 'low' }); }); }); + +describe('createMessagesRequestBody tool search deferral', () => { + let disposables: DisposableStore; + let instantiationService: IInstantiationService; + + function createMockEndpoint(supportsToolSearch: boolean): IChatEndpoint { + return { + model: 'claude-sonnet-4.6', + family: 'claude-sonnet-4.6', + modelProvider: 'Anthropic', + maxOutputTokens: 8192, + modelMaxPromptTokens: 200000, + supportsToolCalls: true, + supportsVision: true, + supportsPrediction: false, + supportsToolSearch, + showInModelPicker: true, + isFallback: false, + name: 'test', + version: '1.0', + policy: 'enabled', + urlOrRequestMetadata: 'https://test.com', + tokenizer: 0, + isDefault: false, + processResponseFromChatEndpoint: () => { throw new Error('not implemented'); }, + acceptChatPolicy: () => { throw new Error('not implemented'); }, + makeChatRequest2: () => { throw new Error('not implemented'); }, + createRequestBody: () => { throw new Error('not implemented'); }, + cloneWithTokenOverride: () => { throw new Error('not implemented'); }, + interceptBody: () => { }, + getExtraHeaders: () => ({}), + } as unknown as IChatEndpoint; + } + + function makeTool(name: string) { + return { type: 'function' as const, function: { name, description: `${name} tool`, parameters: { type: 'object', properties: {} } } }; + } + + function createOptions(tools: ReturnType[]): ICreateEndpointBodyOptions { + return { + debugName: 'test', + requestId: 'test-request-id', + finishedCb: undefined, + messages: [{ + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }], + }], + postOptions: { max_tokens: 8192 }, + location: ChatLocation.Agent, + modelCapabilities: { enableToolSearch: true }, + requestOptions: { tools }, + } as ICreateEndpointBodyOptions; + } + + beforeEach(() => { + disposables = new DisposableStore(); + const services = disposables.add(createPlatformServices(disposables)); + // Non-deferred allowlist matches production: core tools + tool_search itself. + const nonDeferred = new Set(['read_file', 'grep_search', CUSTOM_TOOL_SEARCH_NAME]); + services.define(IToolDeferralService, { + _serviceBrand: undefined, + isNonDeferredTool: (name: string) => nonDeferred.has(name), + }); + const accessor = services.createTestingAccessor(); + instantiationService = accessor.get(IInstantiationService); + }); + + test('does not set defer_loading when tool_search is not in the request tool list', () => { + // Repro for https://github.com/microsoft/vscode/issues/311946: a custom agent + // with `tools: ['my-mcp-server/*']` filters out tool_search. Without this gate, + // every MCP tool gets defer_loading=true and Anthropic rejects the request with + // "At least one tool must have defer_loading=false." + const endpoint = createMockEndpoint(true); + const options = createOptions([makeTool('some_mcp_tool'), makeTool('another_mcp_tool')]); + + const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint); + + const tools = body.tools as AnthropicMessagesTool[]; + expect(tools.every(t => !t.defer_loading)).toBe(true); + expect(tools.find(t => t.name === 'some_mcp_tool')).toBeDefined(); + expect(tools.find(t => t.name === 'another_mcp_tool')).toBeDefined(); + }); + + test('defers MCP tools when tool_search is in the request tool list', () => { + const endpoint = createMockEndpoint(true); + const options = createOptions([ + makeTool('read_file'), + makeTool('some_mcp_tool'), + makeTool(CUSTOM_TOOL_SEARCH_NAME), + ]); + + const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint); + + const tools = body.tools as AnthropicMessagesTool[]; + expect(tools.find(t => t.name === 'read_file')?.defer_loading).toBeUndefined(); + expect(tools.find(t => t.name === CUSTOM_TOOL_SEARCH_NAME)?.defer_loading).toBeUndefined(); + expect(tools.find(t => t.name === 'some_mcp_tool')?.defer_loading).toBe(true); + }); + + test('does not defer when endpoint does not support tool search', () => { + const endpoint = createMockEndpoint(false); + const options = createOptions([makeTool('read_file'), makeTool('some_mcp_tool'), makeTool(CUSTOM_TOOL_SEARCH_NAME)]); + + const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint); + + const tools = body.tools as AnthropicMessagesTool[]; + expect(tools.every(t => !t.defer_loading)).toBe(true); + }); +}); diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts index 7e93cb0b298ad..6f8be91bd4fa6 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts @@ -340,7 +340,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { // Step 1: Write a sentinel file into the sandbox-provided $TMPDIR. const writeOutput = await invokeRunInTerminal(`echo ${marker} > "$TMPDIR/${sentinelName}" && echo ${marker}`); - assert.strictEqual(writeOutput.trim(), marker); + assert.ok(writeOutput.trim().endsWith(marker), `Unexpected output: ${JSON.stringify(writeOutput.trim())}`); // Step 2: Retry with requestUnsandboxedExecution=true while sandbox // stays enabled. The tool should preserve $TMPDIR from the sandbox so @@ -351,7 +351,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { requestUnsandboxedExecutionReason: 'Need to verify $TMPDIR persists on unsandboxed retry', }); const trimmed = retryOutput.trim(); - assert.ok(trimmed.startsWith('Note: The tool simplified the command to'), `Unexpected output: ${JSON.stringify(trimmed)}`); + assert.ok(trimmed.includes('Note: The tool simplified the command to'), `Unexpected output: ${JSON.stringify(trimmed)}`); assert.ok(trimmed.includes(`cat "$TMPDIR/${sentinelName}"`), `Unexpected output: ${JSON.stringify(trimmed)}`); assert.ok(trimmed.endsWith(marker), `Unexpected output: ${JSON.stringify(trimmed)}`); }); @@ -378,13 +378,17 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { const trimmed = output.trim(); // macOS: "# List of acceptable shells for chpass(1)." // Linux: "# /etc/shells: valid login shells" + // On headless Linux CI, Electron/Chromium may emit DBus stderr lines + // before the actual command output, so check the *last* line rather + // than requiring the whole trimmed buffer to start with '#'. + const lastLine = trimmed.split('\n').pop() ?? ''; assert.ok( - trimmed.startsWith('#'), + lastLine.startsWith('#'), `Expected a comment line from /etc/shells, got: ${trimmed}` ); }); - test('can write inside the workspace folder', async function () { + test.skip('can write inside the workspace folder', async function () { this.timeout(60000); const marker = `SANDBOX_WS_${Date.now()}`; @@ -399,7 +403,12 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { const marker = `SANDBOX_TMPDIR_${Date.now()}`; const output = await invokeRunInTerminal(`echo "${marker}" > "$TMPDIR/${marker}.tmp" && cat "$TMPDIR/${marker}.tmp" && rm "$TMPDIR/${marker}.tmp"`); - assert.strictEqual(output.trim(), marker); + // On headless Linux CI, Electron/Chromium may emit DBus stderr lines + // before the actual command output, so check the *last* line rather + // than requiring the entire trimmed output to equal the marker. + const trimmed = output.trim(); + const lastLine = trimmed.split('\n').pop() ?? ''; + assert.strictEqual(lastLine, marker, `Unexpected output: ${JSON.stringify(trimmed)}`); }); test('non-allowlisted domains trigger unsandboxed confirmation flow', async function () { diff --git a/package-lock.json b/package-lock.json index b09cf211da1b7..477c2579e7626 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@anthropic-ai/sandbox-runtime": "0.0.42", + "@anthropic-ai/sandbox-runtime": "0.0.49", "@github/copilot": "^1.0.34", "@github/copilot-sdk": "^0.2.2", "@microsoft/1ds-core-js": "^3.2.13", @@ -188,15 +188,13 @@ } }, "node_modules/@anthropic-ai/sandbox-runtime": { - "version": "0.0.42", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.42.tgz", - "integrity": "sha512-kJpuhU4hHMumeygIkKvNhscEsTtQK1sat1kZwhb6HLYBznwjMGOdnuBI/RM9HeFwxArn71/ciD2WJbxttXBMHw==", + "version": "0.0.49", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.49.tgz", + "integrity": "sha512-t8Ggc0A7UizxMGPk/ANEH8nwnCqzNWIKpkdKgxDVUaKNMQnMzzWR6aErrqIdU03/ZP5RN6/OL/kjFOw/Vox3KQ==", "license": "Apache-2.0", "dependencies": { "@pondwader/socks5-server": "^1.0.10", - "@types/lodash-es": "^4.17.12", "commander": "^12.1.0", - "lodash-es": "^4.17.23", "shell-quote": "^1.8.3", "zod": "^3.24.1" }, @@ -2759,21 +2757,6 @@ "@types/node": "*" } }, - "node_modules/@types/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", - "license": "MIT" - }, - "node_modules/@types/lodash-es": { - "version": "4.17.12", - "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", - "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", - "license": "MIT", - "dependencies": { - "@types/lodash": "*" - } - }, "node_modules/@types/minimatch": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", @@ -13308,12 +13291,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash-es": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", - "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", - "license": "MIT" - }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", diff --git a/package.json b/package.json index 8d32f1f41eca0..829ab071f3f2f 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "install-latest-component-explorer": "npm install @vscode/component-explorer@next @vscode/component-explorer-cli@next && cd build/rspack && npm install @vscode/component-explorer-webpack-plugin@next @vscode/component-explorer@next && cd ../vite && npm install @vscode/component-explorer-vite-plugin@next @vscode/component-explorer@next" }, "dependencies": { - "@anthropic-ai/sandbox-runtime": "0.0.42", + "@anthropic-ai/sandbox-runtime": "0.0.49", "@github/copilot": "^1.0.34", "@github/copilot-sdk": "^0.2.2", "@microsoft/1ds-core-js": "^3.2.13", diff --git a/remote/package-lock.json b/remote/package-lock.json index 65394f951c81e..5cd9518aae58f 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -8,7 +8,7 @@ "name": "vscode-reh", "version": "0.0.0", "dependencies": { - "@anthropic-ai/sandbox-runtime": "0.0.42", + "@anthropic-ai/sandbox-runtime": "0.0.49", "@github/copilot": "^1.0.34", "@github/copilot-sdk": "^0.2.2", "@microsoft/1ds-core-js": "^3.2.13", @@ -54,15 +54,13 @@ } }, "node_modules/@anthropic-ai/sandbox-runtime": { - "version": "0.0.42", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.42.tgz", - "integrity": "sha512-kJpuhU4hHMumeygIkKvNhscEsTtQK1sat1kZwhb6HLYBznwjMGOdnuBI/RM9HeFwxArn71/ciD2WJbxttXBMHw==", + "version": "0.0.49", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.49.tgz", + "integrity": "sha512-t8Ggc0A7UizxMGPk/ANEH8nwnCqzNWIKpkdKgxDVUaKNMQnMzzWR6aErrqIdU03/ZP5RN6/OL/kjFOw/Vox3KQ==", "license": "Apache-2.0", "dependencies": { "@pondwader/socks5-server": "^1.0.10", - "@types/lodash-es": "^4.17.12", "commander": "^12.1.0", - "lodash-es": "^4.17.23", "shell-quote": "^1.8.3", "zod": "^3.24.1" }, @@ -582,21 +580,6 @@ "node": ">= 10" } }, - "node_modules/@types/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", - "license": "MIT" - }, - "node_modules/@types/lodash-es": { - "version": "4.17.12", - "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", - "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", - "license": "MIT", - "dependencies": { - "@types/lodash": "*" - } - }, "node_modules/@vscode/deviceid": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/@vscode/deviceid/-/deviceid-0.1.4.tgz", @@ -1219,12 +1202,6 @@ "node": ">=12.9.0" } }, - "node_modules/lodash-es": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", - "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", - "license": "MIT" - }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", diff --git a/remote/package.json b/remote/package.json index 0143b6c5ce3d6..123ff2899d7f7 100644 --- a/remote/package.json +++ b/remote/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "dependencies": { - "@anthropic-ai/sandbox-runtime": "0.0.42", + "@anthropic-ai/sandbox-runtime": "0.0.49", "@github/copilot": "^1.0.34", "@github/copilot-sdk": "^0.2.2", "@microsoft/1ds-core-js": "^3.2.13", diff --git a/resources/linux/debian/postinst.template b/resources/linux/debian/postinst.template index 5ee1ebb5af529..fa21fc470f1a0 100755 --- a/resources/linux/debian/postinst.template +++ b/resources/linux/debian/postinst.template @@ -29,13 +29,35 @@ if [ "@@NAME@@" != "code-oss" ]; then fi # Register apt repository - eval $(apt-config shell APT_SOURCE_PARTS Dir::Etc::sourceparts/d) + eval $(apt-config shell APT_SOURCE_PARTS Dir::Etc::sourceparts/d APT_SOURCES_LIST Dir::Etc::sourcelist/f) CODE_SOURCE_PART=${APT_SOURCE_PARTS}vscode.list CODE_SOURCE_PART_DEB822=${APT_SOURCE_PARTS}vscode.sources CODE_TRUSTED_PART=/usr/share/keyrings/microsoft.gpg CODE_TRUSTED_PART_OLD="/etc/apt/trusted.gpg.d/microsoft.gpg" + has_existing_repo_source() { + for source_file in "${APT_SOURCE_PARTS}"*.list "${APT_SOURCE_PARTS}"*.sources "$APT_SOURCES_LIST"; do + if [ ! -f "$source_file" ]; then + continue + fi + + # Classic apt source list entry, for example: + # deb [arch=amd64] https://packages.microsoft.com/repos/code stable main + if grep -Eiq "^[[:space:]]*deb[[:space:]].*https://packages\\.microsoft\\.com/repos/code/?([[:space:]]|$)" "$source_file"; then + return 0 + fi + + # DEB822 source entry, for example: + # URIs: https://packages.microsoft.com/repos/code + if grep -Eiq "^[[:space:]]*URIs:[[:space:]]*https://packages\\.microsoft\\.com/repos/code/?([[:space:]]|$)" "$source_file"; then + return 0 + fi + done + + return 1 + } + # RET seems to be true by default even after db_get is called on a first install. RET='true' if [ -e '/usr/share/debconf/confmodule' ]; then @@ -54,6 +76,9 @@ if [ "@@NAME@@" != "code-oss" ]; then elif [ -f "$CODE_SOURCE_PART_DEB822" ]; then # The user is on the new DEB822 format, but refresh the file contents WRITE_SOURCE='yes' + elif has_existing_repo_source; then + # Another source list file already maps to this repository + WRITE_SOURCE='no' elif [ -f /etc/rpi-issue ]; then # Do not write on Raspberry Pi OS # https://github.com/microsoft/vscode/issues/118825 diff --git a/src/vs/code/electron-main/main.ts b/src/vs/code/electron-main/main.ts index e5c7bf736e29b..ae28c78c219cb 100644 --- a/src/vs/code/electron-main/main.ts +++ b/src/vs/code/electron-main/main.ts @@ -200,7 +200,7 @@ class CodeMain { services.set(IStateService, stateService); // User Data Profiles - const userDataProfilesMainService = new UserDataProfilesMainService(stateService, uriIdentityService, environmentMainService, fileService, logService); + const userDataProfilesMainService = new UserDataProfilesMainService(stateService, uriIdentityService, environmentMainService, fileService, logService, productService); services.set(IUserDataProfilesMainService, userDataProfilesMainService); // Use FileUserDataProvider for user data to diff --git a/src/vs/code/node/cli.ts b/src/vs/code/node/cli.ts index 43ea73d798b41..2cfef361c3bdf 100644 --- a/src/vs/code/node/cli.ts +++ b/src/vs/code/node/cli.ts @@ -249,12 +249,14 @@ export async function main(argv: string[]): Promise { const tempUserDataDir = join(tempParentDir, 'data'); const tempExtensionsDir = join(tempParentDir, 'extensions'); const tempSharedDataDir = join(tempParentDir, 'shared'); + const tempAgentPluginsDir = join(tempParentDir, 'agent-plugins'); addArg(argv, '--user-data-dir', tempUserDataDir); addArg(argv, '--extensions-dir', tempExtensionsDir); addArg(argv, '--shared-data-dir', tempSharedDataDir); + addArg(argv, '--agent-plugins-dir', tempAgentPluginsDir); - console.log(`State is temporarily stored. Relaunch this state with: ${product.applicationName} --user-data-dir "${tempUserDataDir}" --extensions-dir "${tempExtensionsDir}" --shared-data-dir "${tempSharedDataDir}"`); + console.log(`State is temporarily stored. Relaunch this state with: ${product.applicationName} --user-data-dir "${tempUserDataDir}" --extensions-dir "${tempExtensionsDir}" --shared-data-dir "${tempSharedDataDir}" --agent-plugins-dir "${tempAgentPluginsDir}"`); } const hasReadStdinArg = args._.some(arg => arg === '-') || args.chat?._.some(arg => arg === '-'); diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index bf286e095d4a6..4ddc4975a3a74 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -20,7 +20,7 @@ import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentResolv import { AgentSubscriptionManager, type IAgentSubscription } from '../common/state/agentSubscription.js'; import { agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../common/agentHostUri.js'; import type { ClientNotificationMap, CommandMap, JsonRpcErrorResponse, JsonRpcRequest } from '../common/state/protocol/messages.js'; -import type { ActionEnvelope, INotification, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; +import type { ActionEnvelope, INotification, RootAction, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; import { SessionSummary, SessionStatus, ROOT_STATE_URI, StateComponents, type RootState } from '../common/state/sessionState.js'; import { PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; @@ -182,7 +182,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC return this._subscriptionManager.getSubscriptionUnmanaged(resource); } - dispatch(action: SessionAction | TerminalAction): void { + dispatch(action: RootAction | SessionAction | TerminalAction): void { const seq = this._subscriptionManager.dispatchOptimistic(action); this.dispatchAction(action, this._clientId, seq); } @@ -205,7 +205,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC /** * Dispatch a client action to the server. Returns the clientSeq used. */ - dispatchAction(action: SessionAction | TerminalAction, _clientId: string, clientSeq: number): void { + dispatchAction(action: RootAction | SessionAction | TerminalAction, _clientId: string, clientSeq: number): void { this._sendNotification('dispatchAction', { clientSeq, action }); } diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 9a7dcfd63b235..33ebc3dacf585 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -95,18 +95,29 @@ export interface ISchema { */ assertValid(key: K, value: unknown): asserts value is SchemaValue; /** - * Returns a fully-typed values bag by validating each key of - * `defaults` against `values` and falling back to the default when + * Returns a fully-typed values bag by validating each key of the + * schema against `values` and falling back to the default when * the incoming value is missing or fails validation. * + * Semantics: for every key declared in the schema `definition`: + * - if `values[key]` validates, it is kept; + * - else if `key` is present in `defaults`, the default is used; + * - else the key is omitted from the result. + * + * This means callers MAY supply defaults for only a subset of the + * schema — keys not present in `defaults` are simply left unset + * when the incoming value is missing or invalid. This is useful + * when some properties (e.g. per-session `permissions`) should be + * inherited from a higher scope rather than materialized on every + * new session. + * * Intended for sanitizing untrusted input at protocol boundaries - * (e.g. `resolveSessionConfig`), where callers want a complete - * type-safe object rather than a throw-on-first-error response. - * Keys that fail validation are silently replaced with their - * default; use {@link values} or {@link assertValid} when you want - * a descriptive {@link ProtocolError} instead. + * (e.g. `resolveSessionConfig`). Keys that fail validation are + * silently replaced with their default or dropped; use + * {@link values} or {@link assertValid} when you want a descriptive + * {@link ProtocolError} instead. */ - validateOrDefault }>(values: Record | undefined, defaults: T): T; + validateOrDefault }>>(values: { [K in keyof T]?: unknown } | undefined, defaults: T): T; } export function createSchema(definition: D): ISchema { @@ -147,14 +158,19 @@ export function createSchema(definition: D): ISchema const narrowed: ISchemaProperty = prop; narrowed.assertValid(value, key); }, - validateOrDefault }>(values: Record | undefined, defaults: T): T { + validateOrDefault }>>(values: { [K in keyof T]?: unknown } | undefined, defaults: T): T { const result: Record = {}; - for (const key of Object.keys(defaults)) { - const raw = values?.[key]; + const raw: { [K in keyof T]?: unknown } = values ?? {}; + for (const key of Object.keys(definition)) { const prop = definition[key]; - result[key] = prop && raw !== undefined && prop.validate(raw) - ? raw - : (defaults as Record)[key]; + const candidate = raw[key]; + if (candidate !== undefined && prop.validate(candidate)) { + result[key] = candidate; + } else if (Object.prototype.hasOwnProperty.call(defaults, key)) { + result[key] = (defaults as Record)[key]; + } + // else: key not in defaults and incoming value missing/invalid + // → leave unset so higher-scope defaults can fill in. } return result as T; }, @@ -249,6 +265,32 @@ export interface IPermissionsValue { readonly deny: readonly string[]; } +const permissionsProperty = schemaProperty({ + type: 'object', + title: localize('agentHost.sessionConfig.permissions', "Permissions"), + description: localize('agentHost.sessionConfig.permissionsDescription', "Per-tool session permissions. Updated automatically when approving a tool \"in this Session\"."), + properties: { + allow: { + type: 'array', + title: localize('agentHost.sessionConfig.permissions.allow', "Allowed tools"), + items: { + type: 'string', + title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"), + }, + }, + deny: { + type: 'array', + title: localize('agentHost.sessionConfig.permissions.deny', "Denied tools"), + items: { + type: 'string', + title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"), + }, + }, + }, + default: { allow: [], deny: [] }, + sessionMutable: true, +}); + /** * Session-config properties owned by the platform itself — i.e. consumed * by the agent host rather than by any particular agent. @@ -276,29 +318,19 @@ export const platformSessionSchema = createSchema({ default: 'default', sessionMutable: true, }), - [SessionConfigKey.Permissions]: schemaProperty({ - type: 'object', - title: localize('agentHost.sessionConfig.permissions', "Permissions"), - description: localize('agentHost.sessionConfig.permissionsDescription', "Per-tool session permissions. Updated automatically when approving a tool \"in this Session\"."), - properties: { - allow: { - type: 'array', - title: localize('agentHost.sessionConfig.permissions.allow', "Allowed tools"), - items: { - type: 'string', - title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"), - }, - }, - deny: { - type: 'array', - title: localize('agentHost.sessionConfig.permissions.deny', "Denied tools"), - items: { - type: 'string', - title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"), - }, - }, - }, - default: { allow: [], deny: [] }, - sessionMutable: true, - }), + [SessionConfigKey.Permissions]: permissionsProperty, +}); + +/** + * Root (agent host) config properties owned by the platform itself. + * + * Root config acts as the baseline that applies to every session: + * + * - {@link SessionConfigKey.Permissions} — host-wide allow/deny lists + * unioned with each session's own permissions when evaluating tool + * auto-approval. See `SessionPermissionManager` for the evaluation + * rules. + */ +export const platformRootSchema = createSchema({ + [SessionConfigKey.Permissions]: permissionsProperty, }); diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 6643dbe30c974..015259b1a24ff 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -13,7 +13,7 @@ import type { ISyncedCustomization } from './agentPluginManager.js'; import type { IAgentSubscription } from './state/agentSubscription.js'; import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js'; import { ProtectedResourceMetadata, type ConfigSchema, type FileEdit, type ModelSelection, type SessionActiveClient, type ToolDefinition } from './state/protocol/state.js'; -import type { ActionEnvelope, INotification, SessionAction, TerminalAction } from './state/sessionActions.js'; +import type { ActionEnvelope, INotification, RootAction, SessionAction, TerminalAction } from './state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult, IStateSnapshot } from './state/sessionProtocol.js'; import { AttachmentType, ComponentToState, SessionInputResponseKind, SessionStatus, StateComponents, type CustomizationRef, type PendingMessage, type RootState, type SessionInputAnswer, type SessionInputRequest, type ToolCallResult, type ToolResultContent, type PolicyState, type StringOrMarkdown } from './state/sessionState.js'; @@ -615,7 +615,7 @@ export interface IAgentService { * it to state, triggers side effects, and echoes it back via * {@link onDidAction} with the client's origin for reconciliation. */ - dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void; + dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void; /** * List the contents of a directory on the agent host's filesystem. @@ -668,7 +668,7 @@ export interface IAgentConnection { getSubscriptionUnmanaged(kind: T, resource: URI): IAgentSubscription | undefined; // ---- Action dispatch ---------------------------------------------------- - dispatch(action: SessionAction | TerminalAction): void; + dispatch(action: RootAction | SessionAction | TerminalAction): void; // ---- Events (connection-level) ------------------------------------------ readonly onDidNotification: Event; diff --git a/src/vs/platform/agentHost/common/state/agentSubscription.ts b/src/vs/platform/agentHost/common/state/agentSubscription.ts index c36b92cd96320..26fd1e5be5e22 100644 --- a/src/vs/platform/agentHost/common/state/agentSubscription.ts +++ b/src/vs/platform/agentHost/common/state/agentSubscription.ts @@ -453,7 +453,7 @@ export class AgentSubscriptionManager extends Disposable { * Dispatch a client action. Applies optimistically to the relevant * subscription if applicable, then returns the clientSeq. */ - dispatchOptimistic(action: SessionAction | TerminalAction): number { + dispatchOptimistic(action: RootAction | SessionAction | TerminalAction): number { if (isSessionAction(action)) { const entry = this._subscriptions.get(URI.parse(action.session)); if (entry && entry.sub instanceof SessionStateSubscription) { diff --git a/src/vs/platform/agentHost/electron-browser/agentHostService.ts b/src/vs/platform/agentHost/electron-browser/agentHostService.ts index 44362189ea1ea..9d36a94fa9024 100644 --- a/src/vs/platform/agentHost/electron-browser/agentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/agentHostService.ts @@ -17,7 +17,7 @@ import { ILogService } from '../../log/common/log.js'; import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostService, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IAgentHostSocketInfo, IConnectionTrackerService } from '../common/agentService.js'; import { AgentSubscriptionManager, type IAgentSubscription } from '../common/state/agentSubscription.js'; import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; -import type { ActionEnvelope, INotification, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; +import type { ActionEnvelope, INotification, RootAction, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; import { StateComponents, ROOT_STATE_URI, type RootState } from '../common/state/sessionState.js'; import { revive } from '../../../base/common/marshalling.js'; @@ -160,7 +160,7 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { unsubscribe(resource: URI): void { this._proxy.unsubscribe(resource); } - dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void { + dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void { this._proxy.dispatchAction(action, clientId, clientSeq); } private _nextSeq = 1; @@ -180,7 +180,7 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { return this._subscriptionManager.getSubscriptionUnmanaged(resource); } - dispatch(action: SessionAction | TerminalAction): void { + dispatch(action: RootAction | SessionAction | TerminalAction): void { const seq = this._subscriptionManager.dispatchOptimistic(action); this.dispatchAction(action, this.clientId, seq); } diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 92b5fb624c762..9bb890e45f401 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -11,6 +11,8 @@ import { ActionType, NotificationType, ActionEnvelope, ActionOrigin, INotificati import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer } from '../common/state/sessionReducers.js'; import { createRootState, createSessionState, SessionLifecycle, type RootState, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI } from '../common/state/sessionState.js'; +import { IPermissionsValue, platformRootSchema } from '../common/agentHostSchema.js'; +import { SessionConfigKey } from '../common/sessionConfigKeys.js'; /** * Server-side state manager for the sessions process protocol. @@ -46,6 +48,19 @@ export class AgentHostStateManager extends Disposable { ) { super(); this._rootState = createRootState(); + // Seed the host-level configuration schema + default values so that + // RootConfigChanged actions can merge into it, and clients see the + // schema immediately upon subscribing to `agenthost:/root`. See + // `platformRootSchema` for the set of platform-owned properties. + this._rootState = { + ...this._rootState, + config: { + schema: platformRootSchema.toProtocol(), + values: platformRootSchema.validateOrDefault({}, { + [SessionConfigKey.Permissions]: { allow: [], deny: [] } satisfies IPermissionsValue, + }), + }, + }; } private readonly _log = (msg: string) => this._logService.warn(`[AgentHostStateManager] ${msg}`); @@ -228,7 +243,7 @@ export class AgentHostStateManager extends Disposable { * The action is applied to state and emitted with the client's origin * so the originating client can reconcile. */ - dispatchClientAction(action: SessionAction | TerminalAction, origin: ActionOrigin): unknown { + dispatchClientAction(action: RootAction | SessionAction | TerminalAction, origin: ActionOrigin): unknown { return this._applyAndEmit(action, origin); } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index c6a4983c21afa..abf84f27c3cd5 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -16,7 +16,7 @@ import { ServiceCollection } from '../../instantiation/common/serviceCollection. import { ILogService } from '../../log/common/log.js'; import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentMessageEvent, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent, AuthenticateParams, AuthenticateResult } from '../common/agentService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; -import { ActionType, ActionEnvelope, INotification, SessionAction, TerminalAction, isSessionAction } from '../common/state/sessionActions.js'; +import { ActionType, ActionEnvelope, INotification, RootAction, SessionAction, TerminalAction, isSessionAction } from '../common/state/sessionActions.js'; import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, parseSubagentSessionUri, type ResponsePart, type SessionConfigState, type ISessionFileDiff, type SessionSummary, type ToolCallCompletedState, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; @@ -415,7 +415,7 @@ export class AgentService extends Disposable implements IAgentService { // in Phase 4 (multi-client). For now this is a no-op. } - dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void { + dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void { this._logService.trace(`[AgentService] dispatchAction: type=${action.type}, clientId=${clientId}, clientSeq=${clientSeq}`, action); const origin = { clientId, clientSeq }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 7b2fdad114812..de102fdedde7f 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -24,7 +24,7 @@ import { IInstantiationService } from '../../../instantiation/common/instantiati import { ILogService } from '../../../log/common/log.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentDeltaEvent, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; -import { AutoApproveLevel, IPermissionsValue, ISchemaProperty, createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; +import { AutoApproveLevel, ISchemaProperty, createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; @@ -620,7 +620,10 @@ export class CopilotAgent extends Disposable implements IAgent { const values = sessionSchema.validateOrDefault(params.config, { [SessionConfigKey.Isolation]: isolationValue, [SessionConfigKey.AutoApprove]: 'default' satisfies AutoApproveLevel, - [SessionConfigKey.Permissions]: { allow: [], deny: [] } satisfies IPermissionsValue, + // Permissions intentionally omitted — leave unset so auto-approval + // falls through to the host-level `permissions` default, and only + // materializes on the session once the user hits "Allow in this + // Session". ...(branchDefault !== undefined ? { [SessionConfigKey.Branch]: branchDefault } : {}), }); diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index d01dd2de427d5..b0467bfb5c74a 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -12,7 +12,7 @@ import { ILogService } from '../../log/common/log.js'; import { AHPFileSystemProvider } from '../common/agentHostFileSystemProvider.js'; import { AgentSession, type IAgentService } from '../common/agentService.js'; import type { CommandMap } from '../common/state/protocol/messages.js'; -import { ActionEnvelope, INotification, isSessionAction, isTerminalAction, type SessionAction } from '../common/state/sessionActions.js'; +import { ActionEnvelope, INotification, isSessionAction, isTerminalAction, type RootAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; import { MIN_PROTOCOL_VERSION, PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; import { AHP_AUTH_REQUIRED, @@ -181,7 +181,7 @@ export class ProtocolServerHandler extends Disposable { case 'dispatchAction': if (client) { this._logService.trace(`[ProtocolServer] dispatchAction: ${JSON.stringify(msg.params.action.type)}`); - const action = msg.params.action as SessionAction; + const action = msg.params.action as RootAction | SessionAction | TerminalAction; this._agentService.dispatchAction(action, client.clientId, msg.params.clientSeq); } break; diff --git a/src/vs/platform/agentHost/node/sessionPermissions.ts b/src/vs/platform/agentHost/node/sessionPermissions.ts index eaf18711995dd..304fc5fa8b430 100644 --- a/src/vs/platform/agentHost/node/sessionPermissions.ts +++ b/src/vs/platform/agentHost/node/sessionPermissions.ts @@ -238,10 +238,13 @@ export class SessionPermissionManager extends Disposable { if (!toolName) { return false; } + // `getEffectiveValue` walks session → parent → host, so sessions + // that haven't materialized their own `permissions` yet transparently + // inherit from the host-level allow/deny lists. const permissions = this._configService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.Permissions); const allowed = permissions?.allow.includes(toolName) ?? false; if (allowed) { - this._logService.trace(`[SessionPermissionManager] Auto-approving "${toolName}" via session permissions`); + this._logService.trace(`[SessionPermissionManager] Auto-approving "${toolName}" via permissions`); } return allowed; } diff --git a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts index 8a3f806f1536e..eaee2c92cc130 100644 --- a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts @@ -254,9 +254,29 @@ suite('agentHostSchema', () => { test('ignores keys not in defaults', () => { const schema = fixture(); + // @ts-expect-error: test that extra keys not in the defaults are ignored, even if they pass validation. const result = schema.validateOrDefault({ name: 'a', count: 1, ignored: true }, { name: 'd', count: 0 }); assert.deepStrictEqual(result, { name: 'a', count: 1 }); }); + + test('omits schema keys that are missing from both values and defaults', () => { + // Regression coverage for the partial-defaults contract that + // underpins host-level inheritance: if the caller doesn't supply + // a default and no incoming value is valid, the key is left out + // entirely so higher-scope defaults can fill in. + const schema = fixture(); + const result = schema.validateOrDefault({ count: 9 }, { count: 0 }); + assert.deepStrictEqual(result, { count: 9 }); + assert.ok(!result.hasOwnProperty('name'), '`name` should be absent when neither values nor defaults supply it'); + }); + + test('omits schema keys when value is invalid and no default is supplied', () => { + const schema = fixture(); + // @ts-expect-error: test that invalid values are dropped even when the caller doesn't provide a default. + const result = schema.validateOrDefault({ name: 42, count: 3 }, { count: 0 }); + // `name` has no default and the incoming value is invalid → dropped. + assert.deepStrictEqual(result, { count: 3 }); + }); }); // ---- platformSessionSchema sanity -------------------------------------- diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index cf5d8aab4c183..cdbdd9b7505d9 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -61,7 +61,11 @@ suite('AgentHostStateManager', () => { const snapshot = manager.getSnapshot(ROOT_STATE_URI); assert.ok(snapshot); assert.strictEqual(snapshot.resource.toString(), ROOT_STATE_URI.toString()); - assert.deepStrictEqual(snapshot.state, { agents: [], activeSessions: 0 }); + const root = snapshot.state as { agents: unknown[]; activeSessions: number; config?: { values?: Record } }; + assert.deepStrictEqual(root.agents, []); + assert.strictEqual(root.activeSessions, 0); + // Host config is seeded with the platform root schema and defaults. + assert.ok(root.config, 'root state should include a seeded config'); }); test('getSnapshot returns session snapshot after creation', () => { @@ -180,7 +184,9 @@ suite('AgentHostStateManager', () => { test('root state starts with activeSessions: 0', () => { const snapshot = manager.getSnapshot(ROOT_STATE_URI); assert.ok(snapshot); - assert.deepStrictEqual(snapshot.state, { agents: [], activeSessions: 0 }); + const root = snapshot.state as { agents: unknown[]; activeSessions: number }; + assert.deepStrictEqual(root.agents, []); + assert.strictEqual(root.activeSessions, 0); }); test('turnStarted dispatches root/activeSessionsChanged with correct count', () => { diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 191e01f71539e..95e80e6df76b5 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -11,7 +11,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import { type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentService, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agentService.js'; import { ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; -import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; +import { ActionType, type RootAction, type SessionAction, type TerminalAction } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js'; import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; import { SessionStatus, type SessionSummary } from '../../common/state/sessionState.js'; @@ -68,7 +68,7 @@ class MockProtocolServer implements IProtocolServer { class MockAgentService implements IAgentService { declare readonly _serviceBrand: undefined; - readonly handledActions: SessionAction[] = []; + readonly handledActions: (RootAction | SessionAction | TerminalAction)[] = []; readonly browsedUris: URI[] = []; readonly browseErrors = new Map(); readonly listedSessions: IAgentSessionMetadata[] = []; @@ -86,7 +86,7 @@ class MockAgentService implements IAgentService { this._stateManager = sm; } - dispatchAction(action: SessionAction, clientId: string, clientSeq: number): void { + dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void { this.handledActions.push(action); const origin = { clientId, clientSeq }; this._stateManager.dispatchClientAction(action, origin); diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index 28bbcd85e94a4..43ee26dc2f128 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -13,6 +13,7 @@ export enum BrowserViewCommandId { Open = `${commandPrefix}.open`, NewTab = `${commandPrefix}.newTab`, QuickOpen = `${commandPrefix}.quickOpen`, + OpenOrList = `${commandPrefix}.openOrList`, CloseAll = `${commandPrefix}.closeAll`, CloseAllInGroup = `${commandPrefix}.closeAllInGroup`, diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 548dbac4bf645..8926bc648039a 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -115,7 +115,7 @@ export class BrowserView extends Disposable { }); // Use a default size of 1024x768. - this._view.setBounds({ x: 0, y: 0, width: 1024, height: 768 }); + this._view.setBounds({ x: -10000, y: -10000, width: 1024, height: 768 }); this._view.setBackgroundColor('#FFFFFF'); this._ownerWindow = this.windowsMainService.getWindowById(owner.mainWindowId)!; @@ -125,7 +125,7 @@ export class BrowserView extends Disposable { this._register(this._ownerWindow.onDidClose(() => this.dispose())); this._view.setVisible(false); - this._ownerWindow.win?.contentView.addChildView(this._view, 0); + this._ownerWindow.win?.contentView.addChildView(this._view); this._view.webContents.setWindowOpenHandler((details) => { const location = (() => { @@ -592,9 +592,11 @@ export class BrowserView extends Disposable { * Capture a screenshot of this view */ async captureScreenshot(options?: IBrowserViewCaptureScreenshotOptions): Promise { - // This ensures the webContents rendering pipeline is ready so background tabs can be captured too. - this._view.setVisible(true); - this._view.setVisible(false); + if (!this._view.getVisible()) { + // This ensures the webContents rendering pipeline is ready so background tabs can be captured too. + this._view.setVisible(true); + this._view.setVisible(false); + } const quality = options?.quality ?? 80; if (options?.pageRect) { diff --git a/src/vs/platform/environment/common/environmentService.ts b/src/vs/platform/environment/common/environmentService.ts index 1d7dfff7d1723..004d0614c938a 100644 --- a/src/vs/platform/environment/common/environmentService.ts +++ b/src/vs/platform/environment/common/environmentService.ts @@ -162,26 +162,6 @@ export abstract class AbstractNativeEnvironmentService implements INativeEnviron return joinPath(this.userHome, this.productService.sharedDataFolderName); } - @memoize - get agentPluginsPath(): string { - const cliAgentPluginsDir = this.args['agent-plugins-dir']; - if (cliAgentPluginsDir) { - return resolve(cliAgentPluginsDir); - } - - const vscodeAgentPlugins = env['VSCODE_AGENT_PLUGINS']; - if (vscodeAgentPlugins) { - return vscodeAgentPlugins; - } - - const vscodePortable = env['VSCODE_PORTABLE']; - if (vscodePortable) { - return join(vscodePortable, 'agent-plugins'); - } - - return joinPath(this.userHome, this.productService.dataFolderName, 'agent-plugins').fsPath; - } - @memoize get extensionDevelopmentLocationURI(): URI[] | undefined { const extensionDevelopmentPaths = this.args.extensionDevelopmentPath; diff --git a/src/vs/platform/sandbox/common/terminalSandboxService.ts b/src/vs/platform/sandbox/common/terminalSandboxService.ts index f790fbdb1fdb0..ebaa2bd9d225f 100644 --- a/src/vs/platform/sandbox/common/terminalSandboxService.ts +++ b/src/vs/platform/sandbox/common/terminalSandboxService.ts @@ -73,7 +73,7 @@ export interface ITerminalSandboxService { isEnabled(): Promise; getOS(): Promise; checkForSandboxingPrereqs(forceRefresh?: boolean): Promise; - wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string): ITerminalSandboxWrapResult; + wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string, commandKeywords?: readonly string[], cwd?: URI): Promise; getSandboxConfigPath(forceRefresh?: boolean): Promise; getTempDir(): URI | undefined; setNeedsForceUpdateConfigFile(): void; @@ -97,7 +97,7 @@ export class NullTerminalSandboxService implements ITerminalSandboxService { return { enabled: false, sandboxConfigPath: undefined, failedCheck: undefined }; } - wrapCommand(command: string): ITerminalSandboxWrapResult { + async wrapCommand(command: string): Promise { return { command, isSandboxWrapped: false }; } diff --git a/src/vs/platform/storage/test/electron-main/storageMainService.test.ts b/src/vs/platform/storage/test/electron-main/storageMainService.test.ts index 7c54f44d5c914..eef84a5d3517a 100644 --- a/src/vs/platform/storage/test/electron-main/storageMainService.test.ts +++ b/src/vs/platform/storage/test/electron-main/storageMainService.test.ts @@ -63,6 +63,7 @@ suite('StorageMainService', function () { promptsHome: joinPath(inMemoryProfileRoot, 'promptsHome'), extensionsResource: joinPath(inMemoryProfileRoot, 'extensionsResource'), cacheHome: joinPath(inMemoryProfileRoot, 'cache'), + agentPluginsHome: joinPath(inMemoryProfileRoot, 'agentPluginsHome'), }; class TestStorageMainService extends StorageMainService { @@ -131,7 +132,7 @@ suite('StorageMainService', function () { const environmentService = new NativeEnvironmentService(parseArgs(process.argv, OPTIONS), productService); const fileService = disposables.add(new FileService(new NullLogService())); const uriIdentityService = disposables.add(new UriIdentityService(fileService)); - const testStorageService = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(uriIdentityService), environmentService, fileService, new NullLogService())), lifecycleMainService, fileService, uriIdentityService, nullCrossAppIPCService)); + const testStorageService = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(uriIdentityService), environmentService, fileService, new NullLogService(), productService)), lifecycleMainService, fileService, uriIdentityService, nullCrossAppIPCService)); disposables.add(testStorageService.applicationStorage); @@ -300,7 +301,7 @@ suite('StorageMainService', function () { const environmentService = new NativeEnvironmentService(parseArgs(process.argv, OPTIONS), productService); const fileService = disposables.add(new FileService(new NullLogService())); const uriIdentityService = disposables.add(new UriIdentityService(fileService)); - const storageMainService = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(uriIdentityService), environmentService, fileService, new NullLogService())), new TestLifecycleMainService(), fileService, uriIdentityService, crossAppIPCService)); + const storageMainService = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(uriIdentityService), environmentService, fileService, new NullLogService(), productService)), new TestLifecycleMainService(), fileService, uriIdentityService, crossAppIPCService)); const storage = storageMainService.applicationSharedStorage; disposables.add(storage); @@ -336,7 +337,7 @@ suite('StorageMainService', function () { onDidReceiveMessage: onDidReceiveMessage2.event, }; - const storageMainService2 = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(new UriIdentityService(fileService)), environmentService, fileService, new NullLogService())), new TestLifecycleMainService(), fileService, disposables.add(new UriIdentityService(fileService)), crossAppIPCService2)); + const storageMainService2 = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(new UriIdentityService(fileService)), environmentService, fileService, new NullLogService(), productService)), new TestLifecycleMainService(), fileService, disposables.add(new UriIdentityService(fileService)), crossAppIPCService2)); const storage2 = storageMainService2.applicationSharedStorage; disposables.add(storage2); diff --git a/src/vs/platform/userDataProfile/common/userDataProfile.ts b/src/vs/platform/userDataProfile/common/userDataProfile.ts index b2443cded4853..fb2e942ca2bf5 100644 --- a/src/vs/platform/userDataProfile/common/userDataProfile.ts +++ b/src/vs/platform/userDataProfile/common/userDataProfile.ts @@ -54,6 +54,7 @@ export interface IUserDataProfile { readonly promptsHome: URI; readonly extensionsResource: URI; readonly mcpResource: URI; + readonly agentPluginsHome: URI; readonly cacheHome: URI; readonly useDefaultFlags?: UseDefaultProfileFlags; readonly isTransient?: boolean; @@ -76,6 +77,7 @@ export function isUserDataProfile(thing: unknown): thing is IUserDataProfile { && URI.isUri(candidate.promptsHome) && URI.isUri(candidate.extensionsResource) && URI.isUri(candidate.mcpResource) + && URI.isUri(candidate.agentPluginsHome) ); } @@ -154,6 +156,7 @@ export function reviveProfile(profile: UriDto, scheme: string) promptsHome: URI.revive(profile.promptsHome).with({ scheme }), extensionsResource: URI.revive(profile.extensionsResource).with({ scheme }), mcpResource: URI.revive(profile.mcpResource).with({ scheme }), + agentPluginsHome: URI.revive(profile.agentPluginsHome), cacheHome: URI.revive(profile.cacheHome).with({ scheme }), useDefaultFlags: profile.useDefaultFlags, isTransient: profile.isTransient, @@ -176,6 +179,7 @@ export function toUserDataProfile(id: string, name: string, location: URI, profi promptsHome: defaultProfile && options?.useDefaultFlags?.prompts ? defaultProfile.promptsHome : joinPath(location, 'prompts'), extensionsResource: defaultProfile && options?.useDefaultFlags?.extensions ? defaultProfile.extensionsResource : joinPath(location, 'extensions.json'), mcpResource: defaultProfile && options?.useDefaultFlags?.mcp ? defaultProfile.mcpResource : joinPath(location, 'mcp.json'), + agentPluginsHome: defaultProfile ? defaultProfile.agentPluginsHome : joinPath(location, 'agent-plugins'), cacheHome: joinPath(profilesCacheHome, id), useDefaultFlags: options?.useDefaultFlags, isTransient: options?.transient, diff --git a/src/vs/platform/userDataProfile/electron-main/userDataProfile.ts b/src/vs/platform/userDataProfile/electron-main/userDataProfile.ts index e6510943dca88..19842b144ad0a 100644 --- a/src/vs/platform/userDataProfile/electron-main/userDataProfile.ts +++ b/src/vs/platform/userDataProfile/electron-main/userDataProfile.ts @@ -10,11 +10,16 @@ import { INativeEnvironmentService } from '../../environment/common/environment. import { IFileService } from '../../files/common/files.js'; import { refineServiceDecorator } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; +import { IProductService } from '../../product/common/productService.js'; import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js'; import { IUserDataProfilesService, WillCreateProfileEvent, WillRemoveProfileEvent, IUserDataProfile } from '../common/userDataProfile.js'; import { UserDataProfilesService } from '../node/userDataProfile.js'; import { IAnyWorkspaceIdentifier, IEmptyWorkspaceIdentifier } from '../../workspace/common/workspace.js'; import { IStateService } from '../../state/node/state.js'; +import { URI } from '../../../base/common/uri.js'; +import { NativeParsedArgs } from '../../environment/common/argv.js'; +import { env } from '../../../base/common/process.js'; +import { join, resolve } from '../../../base/common/path.js'; export const IUserDataProfilesMainService = refineServiceDecorator(IUserDataProfilesService); export interface IUserDataProfilesMainService extends IUserDataProfilesService { @@ -27,18 +32,25 @@ export interface IUserDataProfilesMainService extends IUserDataProfilesService { export class UserDataProfilesMainService extends UserDataProfilesService implements IUserDataProfilesMainService { + private readonly agentPluginsHome: URI; + constructor( @IStateService stateService: IStateService, @IUriIdentityService uriIdentityService: IUriIdentityService, @INativeEnvironmentService environmentService: INativeEnvironmentService, @IFileService fileService: IFileService, @ILogService logService: ILogService, + @IProductService private readonly productService: IProductService, ) { super(stateService, uriIdentityService, environmentService, fileService, logService); + this.agentPluginsHome = URI.file(getAgentPluginsPath(environmentService.args, environmentService.userHome, productService.dataFolderName)); } protected override createDefaultProfile(): IUserDataProfile { - const defaultProfile = super.createDefaultProfile(); + const defaultProfile = { + ...super.createDefaultProfile(), + agentPluginsHome: this.agentPluginsHome + }; if (!(process as INodeProcess).isEmbeddedApp) { return defaultProfile; } @@ -46,11 +58,13 @@ export class UserDataProfilesMainService extends UserDataProfilesService impleme if (!hostUserRoamingDataHome) { return defaultProfile; } + const hostAgentPluginsHome = getHostAgentPluginsPath(this.nativeEnvironmentService, this.productService); return { ...defaultProfile, keybindingsResource: joinPath(hostUserRoamingDataHome, 'keybindings.json'), promptsHome: joinPath(hostUserRoamingDataHome, 'prompts'), mcpResource: joinPath(hostUserRoamingDataHome, 'mcp.json'), + agentPluginsHome: hostAgentPluginsHome ? URI.file(hostAgentPluginsHome) : this.agentPluginsHome }; } @@ -61,5 +75,46 @@ export class UserDataProfilesMainService extends UserDataProfilesService impleme } return emptyWindows; } +} + +function getHostAgentPluginsPath(environmentService: INativeEnvironmentService, productService: IProductService): string | undefined { + if (!(process as INodeProcess).isEmbeddedApp) { + return undefined; + } + if (!environmentService.isBuilt) { + return undefined; + } + + const quality = productService.quality; + let hostDataFolderName: string; + if (quality === 'stable') { + hostDataFolderName = '.vscode'; + } else if (quality === 'insider') { + hostDataFolderName = '.vscode-insiders'; + } else if (quality === 'exploration') { + hostDataFolderName = '.vscode-exploration'; + } else { + return undefined; + } + + return getAgentPluginsPath(environmentService.args, environmentService.userHome, hostDataFolderName); +} + +function getAgentPluginsPath(args: NativeParsedArgs, userHome: URI, dataFolderName: string): string { + const cliAgentPluginsDir = args['agent-plugins-dir']; + if (cliAgentPluginsDir) { + return resolve(cliAgentPluginsDir); + } + + const vscodeAgentPlugins = env['VSCODE_AGENT_PLUGINS']; + if (vscodeAgentPlugins) { + return vscodeAgentPlugins; + } + + const vscodePortable = env['VSCODE_PORTABLE']; + if (vscodePortable) { + return join(vscodePortable, 'agent-plugins'); + } + return joinPath(userHome, dataFolderName, 'agent-plugins').fsPath; } diff --git a/src/vs/platform/userDataProfile/node/userDataProfile.ts b/src/vs/platform/userDataProfile/node/userDataProfile.ts index 664b85109e97e..a992766f60e34 100644 --- a/src/vs/platform/userDataProfile/node/userDataProfile.ts +++ b/src/vs/platform/userDataProfile/node/userDataProfile.ts @@ -20,7 +20,7 @@ export class UserDataProfilesReadonlyService extends BaseUserDataProfilesService constructor( @IStateReadService private readonly stateReadonlyService: IStateReadService, @IUriIdentityService uriIdentityService: IUriIdentityService, - @INativeEnvironmentService private readonly nativeEnvironmentService: INativeEnvironmentService, + @INativeEnvironmentService protected readonly nativeEnvironmentService: INativeEnvironmentService, @IFileService fileService: IFileService, @ILogService logService: ILogService, ) { diff --git a/src/vs/platform/userDataProfile/test/electron-main/userDataProfileMainService.test.ts b/src/vs/platform/userDataProfile/test/electron-main/userDataProfileMainService.test.ts index ce4bf69348300..24b59d16d8d68 100644 --- a/src/vs/platform/userDataProfile/test/electron-main/userDataProfileMainService.test.ts +++ b/src/vs/platform/userDataProfile/test/electron-main/userDataProfileMainService.test.ts @@ -15,6 +15,7 @@ import product from '../../../product/common/product.js'; import { UserDataProfilesMainService } from '../../electron-main/userDataProfile.js'; import { SaveStrategy, StateService } from '../../../state/node/stateService.js'; import { UriIdentityService } from '../../../uriIdentity/common/uriIdentityService.js'; +import { IProductService } from '../../../product/common/productService.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; const ROOT = URI.file('tests').with({ scheme: 'vscode-tests' }); @@ -23,6 +24,7 @@ class TestEnvironmentService extends AbstractNativeEnvironmentService { constructor(private readonly _appSettingsHome: URI) { super(Object.create(null), Object.create(null), { _serviceBrand: undefined, ...product }); } + override get userHome() { return this._appSettingsHome; } override get userRoamingDataHome() { return this._appSettingsHome.with({ scheme: Schemas.vscodeUserData }); } override get extensionsPath() { return joinPath(this.userRoamingDataHome, 'extensions.json').path; } override get stateResource() { return joinPath(this.userRoamingDataHome, 'state.json'); } @@ -44,7 +46,8 @@ suite('UserDataProfileMainService', () => { environmentService = new TestEnvironmentService(joinPath(ROOT, 'User')); stateService = disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, logService, fileService)); - testObject = disposables.add(new UserDataProfilesMainService(stateService, disposables.add(new UriIdentityService(fileService)), environmentService, fileService, logService)); + const productService: IProductService = { _serviceBrand: undefined, ...product }; + testObject = disposables.add(new UserDataProfilesMainService(stateService, disposables.add(new UriIdentityService(fileService)), environmentService, fileService, logService, productService)); await stateService.init(); }); diff --git a/src/vs/platform/workspaces/test/electron-main/workspacesManagementMainService.test.ts b/src/vs/platform/workspaces/test/electron-main/workspacesManagementMainService.test.ts index 8b870f33a2735..5c68f94a6df19 100644 --- a/src/vs/platform/workspaces/test/electron-main/workspacesManagementMainService.test.ts +++ b/src/vs/platform/workspaces/test/electron-main/workspacesManagementMainService.test.ts @@ -110,7 +110,7 @@ flakySuite('WorkspacesManagementMainService', () => { const logService = new NullLogService(); const fileService = new FileService(logService); - service = new WorkspacesManagementMainService(environmentMainService, logService, new UserDataProfilesMainService(new StateService(SaveStrategy.DELAYED, environmentMainService, logService, fileService), new UriIdentityService(fileService), environmentMainService, fileService, logService), new TestBackupMainService(), new TestDialogMainService()); + service = new WorkspacesManagementMainService(environmentMainService, logService, new UserDataProfilesMainService(new StateService(SaveStrategy.DELAYED, environmentMainService, logService, fileService), new UriIdentityService(fileService), environmentMainService, fileService, logService, productService), new TestBackupMainService(), new TestDialogMainService()); return fs.promises.mkdir(untitledWorkspacesHomePath, { recursive: true }); }); diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 5dc82ac562e2a..f691d6641ea1c 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -51,7 +51,7 @@ Sessions-specific overrides: ``` src/vs/sessions/contrib/chat/browser/ ├── aiCustomizationWorkspaceService.ts # Sessions workspace service override -├── customizationHarnessService.ts # Sessions harness service (CLI harness only) +├── customizationHarnessService.ts # Sessions harness service (accepts any content-provider-backed session type) └── promptsService.ts # AgenticPromptsService (CLI user roots) src/vs/sessions/contrib/sessions/browser/ ├── aiCustomizationShortcutsWidget.ts # Shortcuts widget @@ -92,7 +92,7 @@ Available harnesses: | `claude` | Claude | Restricts user roots to `~/.claude`; hides Prompts + Plugins sections | In core VS Code, all three harnesses are registered but CLI and Claude only appear when their respective agents are registered (`requiredAgentId` checked via `IChatAgentService`). VS Code is the default. -In sessions, only CLI is registered (single harness, toggle bar hidden). +In sessions, harnesses are accepted for any session type that has a registered content provider (checked via `IChatSessionsService.getContentProviderSchemes()`). AHP remote servers register directly via `registerExternalHarness`. ### IHarnessDescriptor @@ -220,7 +220,7 @@ Skills that are directly invoked by UI elements (toolbar buttons, menu items) ar ### Count Consistency -`customizationCounts.ts` uses the **same data sources** as the list widget. Both go through the active harness's `ICustomizationItemProvider` (or the `PromptsServiceCustomizationItemProvider` fallback), ensuring counts match what the list displays. +`customizationCounts.ts` uses the **same data sources** as the list widget. When a harness with an `itemProvider` is active (determined by `getActiveItemProvider()`), counts come from that provider's `provideChatSessionCustomizations()`. Otherwise, both counts and the list go through the `PromptsServiceCustomizationItemProvider` fallback, ensuring counts match what the list displays. ### Item Badges diff --git a/src/vs/sessions/MOBILE.md b/src/vs/sessions/MOBILE.md index 7b18d586d8c66..7b3181e22bf38 100644 --- a/src/vs/sessions/MOBILE.md +++ b/src/vs/sessions/MOBILE.md @@ -13,7 +13,7 @@ Desktop Parts (`ChatBarPart`, `SidebarPart`, `PanelPart`, `AuxiliaryBarPart`) re Each mobile Part checks the current layout class (via `isPhoneLayout(layoutService)`) at every call. When the viewport is phone it applies mobile behavior (full-cell layout, no card chrome, no session-bar subtraction). When the viewport is tablet/desktop — which happens when a real phone rotates past the 640px breakpoint — it delegates to the desktop `super` implementation. This means a `Mobile*Part` instance is safe to keep through a viewport-class transition without producing wrong layout math. This means: -- Desktop code has **zero** phone-layout checks — all mobile logic lives in mobile subclasses, `MobileTopBar`, and CSS. +- Desktop code has **zero** phone-layout checks — all mobile logic lives in mobile subclasses, `MobileTitlebarPart`, and CSS. - Phone-instantiated parts adapt correctly to rotation across the 640px breakpoint by delegating to `super`. After a viewport-class transition the workbench calls `updateStyles()` on each pane composite part so card-chrome inline styles get re-applied (desktop) or cleared (phone) for the new class. @@ -43,7 +43,7 @@ Two registrations can target the same slot with opposite `when` clauses, pointin | "Open in VS Code" action | ❌ Gated | `when: !sessionsIsPhoneLayout` on menu item | | Code review toolbar | ❌ Gated | `when: !sessionsIsPhoneLayout` on menu item | | Customizations toolbar | ❌ Hidden | CSS `display: none` on phone | -| Titlebar | ❌ Hidden | Grid `visible: false` + CSS + MobileTopBar replacement | +| Titlebar | ❌ Hidden | Grid `visible: false` + CSS + MobileTitlebarPart replacement | ### Phone Layout @@ -51,7 +51,7 @@ On phone-sized viewports (`< 640px` width): ``` ┌──────────────────────────────────┐ -│ [☰] Session Title [+] │ ← MobileTopBar (prepended before grid) +│ [☰] Session Title [+] │ ← MobileTitlebarPart (prepended before grid) ├──────────────────────────────────┤ │ │ │ Chat (edge-to-edge) │ ← Grid: ChatBarPart fills 100% @@ -64,9 +64,9 @@ On phone-sized viewports (`< 640px` width): └──────────────────────────────────┘ ``` -- **MobileTopBar** is a DOM element prepended above the grid. It has a hamburger (☰), session title, and new session (+) button. +- **MobileTitlebarPart** is a DOM element prepended above the grid. It has a hamburger (☰), session title, and new session (+) button. - **Sidebar** is hidden by default and opens as an **85% width drawer overlay** with a backdrop when the hamburger is tapped. CSS makes its `split-view-view` absolutely positioned with `z-index: 250`. The workbench manually calls `sidebarPart.layout()` with drawer dimensions after opening. Closing the drawer clears the navigation stack. -- **Titlebar** is hidden in the grid (`visible: false`) and via CSS — replaced by MobileTopBar. +- **Titlebar** is hidden in the grid (`visible: false`) and via CSS — replaced by MobileTitlebarPart. - **SessionCompositeBar** (chat tabs) is hidden via CSS. - The grid uses `display: flex; flex-direction: column` and all `split-view-view:has(> .part)` containers are positioned absolutely at `100% width/height`. @@ -77,7 +77,7 @@ On phone-sized viewports (`< 640px` width): - **tablet**: `640px ≤ width < 1024px` (treated as desktop; no phone-specific chrome) - **desktop**: `width ≥ 1024px` -The workbench toggles the `phone-layout` CSS class on `layout()` and creates/destroys mobile components when the viewport class changes at runtime (e.g., DevTools device emulation, or a real phone rotating across the 640px breakpoint). MobileTopBar lifecycle is managed via a `DisposableStore` that is cleared on viewport transitions to prevent leaks. +The workbench toggles the `phone-layout` CSS class on `layout()` and creates/destroys mobile components when the viewport class changes at runtime (e.g., DevTools device emulation, or a real phone rotating across the 640px breakpoint). MobileTitlebarPart lifecycle is managed via a `DisposableStore` that is cleared on viewport transitions to prevent leaks. ### Context Keys @@ -90,13 +90,13 @@ The workbench toggles the `phone-layout` CSS class on `layout()` and creates/des | Desktop Component | Mobile Equivalent | How Accessed | |---|---|---| -| **Titlebar** (3-section toolbar) | **MobileTopBar** (☰ / title / +) | Always visible at top | +| **Titlebar** (3-section toolbar) | **MobileTitlebarPart** (☰ / title / +) | Always visible at top | | **Sidebar** (sessions list) | Drawer overlay (85% width) | Hamburger button (☰) | | **ChatBar** (chat widget) | Same Part, edge-to-edge, no card chrome | Default view (always visible) | | **AuxiliaryBar** (files, changes) | Gated — not shown on mobile | Planned: mobile-specific view | | **Panel** (terminal, output) | Gated — not shown on mobile | Planned: mobile-specific view | | **SessionCompositeBar** (chat tabs) | Hidden on phone | — | -| **New Session** (sidebar button) | + button in MobileTopBar | Always visible in top bar | +| **New Session** (sidebar button) | + button in MobileTitlebarPart | Always visible in top bar | ## File Map @@ -113,7 +113,7 @@ The workbench toggles the `phone-layout` CSS class on `layout()` and creates/des | File | Purpose | |------|---------| -| `browser/parts/mobile/mobileTopBar.ts` | Phone top bar: hamburger (☰), session title, new session (+). Emits `onDidClickHamburger`, `onDidClickNewSession`, `onDidClickTitle`. | +| `browser/parts/mobile/mobileTitlebarPart.ts` | Phone top bar: hamburger (☰), session title, new session (+). Emits `onDidClickHamburger`, `onDidClickNewSession`, `onDidClickTitle`. | | `browser/parts/mobile/mobileChatShell.css` | **Single source of truth** for all phone-layout CSS: flex column layout, split-view-view absolute positioning, card chrome removal, part/content width overrides, sidebar title hiding, composite bar hiding, welcome page layout, sash hiding, button focus overrides, mobile pickers. | ### Layout & Navigation @@ -134,7 +134,7 @@ The workbench toggles the `phone-layout` CSS class on `layout()` and creates/des | File | Key Changes | |------|-------------| -| `browser/workbench.ts` | Layout policy integration, MobileTopBar creation/destruction (via `DisposableStore`), sidebar drawer open/close with backdrop, viewport-class-change detection, window resize listener, grid height calculation (subtracts MobileTopBar height), titlebar grid visibility toggle, `ISessionsManagementService` for new session button. | +| `browser/workbench.ts` | Layout policy integration, MobileTitlebarPart creation/destruction (via `DisposableStore`), sidebar drawer open/close with backdrop, viewport-class-change detection, window resize listener, grid height calculation (subtracts MobileTitlebarPart height), titlebar grid visibility toggle, `ISessionsManagementService` for new session button. | | `browser/parts/chatBarPart.ts` | `_lastLayout` changed from `private` to `protected` for mobile subclass access. | ### Styling @@ -147,7 +147,6 @@ The workbench toggles the `phone-layout` CSS class on `layout()` and creates/des ## Remaining Work -- **Session title sync**: MobileTopBar shows hardcoded "New Session" — needs to subscribe to `sessionsManagementService.activeSession` and update title when session changes. - **Files & Terminal access**: Should become phone-specific views gated with `when: IsPhoneLayoutContext`. - **iOS keyboard handling**: Adjust layout when virtual keyboard appears (context key exists, but no layout response yet). - **Session list inline actions**: Make always-visible on touch devices (no hover-to-reveal). diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index c81d9ea0704eb..3e449ca83e173 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -17,6 +17,7 @@ export const Menus = { TitleBarSessionTitle: new MenuId('SessionsTitleBarSessionTitle'), TitleBarSessionMenu: new MenuId('SessionsTitleBarSessionMenu'), TitleBarRightLayout: new MenuId('SessionsTitleBarRightLayout'), + MobileTitleBarCenter: new MenuId('SessionsMobileTitleBarCenter'), PanelTitle: new MenuId('SessionsPanelTitle'), SidebarTitle: new MenuId('SessionsSidebarTitle'), SidebarSessionsHeader: new MenuId('SessionsSidebarSessionsHeader'), diff --git a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css index 9e65c868ed321..72eddfcfe05a4 100644 --- a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css +++ b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css @@ -51,6 +51,14 @@ background: var(--vscode-toolbar-hoverBackground); } +.mobile-top-bar .mobile-top-bar-center { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + height: 100%; +} + .mobile-top-bar .mobile-session-title { flex: 1; min-width: 0; @@ -63,12 +71,45 @@ white-space: nowrap; padding: 0 4px; cursor: pointer; + border: none; + background: none; + touch-action: manipulation; + font-family: inherit; +} + +.monaco-workbench .mobile-top-bar .mobile-session-title:focus { + outline: none !important; +} + +.mobile-top-bar .mobile-session-title:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; } .mobile-top-bar .mobile-session-title:active { opacity: 0.7; } +.mobile-top-bar .mobile-top-bar-actions { + display: none; + flex: 1; + min-width: 0; + align-items: center; + height: 100%; +} + +/* When the welcome screen is visible and the center menu has contributed + items (e.g., the web host filter on the home screen) the title is + hidden and the toolbar takes its place. */ +.mobile-top-bar.show-actions .mobile-session-title { + display: none; +} + +.mobile-top-bar.show-actions .mobile-top-bar-actions { + display: flex; + flex-direction: column; +} + /* ---- Phone Layout: Full-screen chat ---- */ /* On phone, stack the mobile top bar and grid vertically */ diff --git a/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts b/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts new file mode 100644 index 0000000000000..550ac0944a315 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './mobileChatShell.css'; +import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { $, addDisposableListener, append, EventType } from '../../../../base/browser/dom.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { localize } from '../../../../nls.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { IsNewChatSessionContext } from '../../../common/contextkeys.js'; +import { Menus } from '../../menus.js'; + +/** + * Mobile titlebar — prepended above the workbench grid on phone viewports + * in place of the desktop titlebar. + * + * Layout: + * + * `[menu] [session title | host widget] [+]` + * + * The center slot switches content based on whether the sessions welcome + * (home/empty) screen is visible: + * + * - **Welcome hidden** → shows the active session title (live, from + * {@link ISessionsManagementService.activeSession}). + * - **Welcome visible** → shows whatever is contributed to the + * {@link Menus.MobileTitleBarCenter} menu. On web, the host filter + * contribution appends its host dropdown + connection button there. + * + * The switch is driven entirely by the menu: when the toolbar has no + * items the title is shown; as soon as it has items the title is hidden + * and the toolbar fills the slot. + */ +export class MobileTitlebarPart extends Disposable { + + readonly element: HTMLElement; + + private readonly sessionTitleElement: HTMLElement; + private readonly actionsContainer: HTMLElement; + + private readonly _onDidClickHamburger = this._register(new Emitter()); + readonly onDidClickHamburger: Event = this._onDidClickHamburger.event; + + private readonly _onDidClickNewSession = this._register(new Emitter()); + readonly onDidClickNewSession: Event = this._onDidClickNewSession.event; + + private readonly _onDidClickTitle = this._register(new Emitter()); + readonly onDidClickTitle: Event = this._onDidClickTitle.event; + + constructor( + parent: HTMLElement, + @IInstantiationService instantiationService: IInstantiationService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(); + + this.element = document.createElement('div'); + this.element.className = 'mobile-top-bar'; + + // Register DOM removal before appending so that any exception + // between this point and the end of the constructor still cleans + // up the element via disposal. + this._register(toDisposable(() => this.element.remove())); + parent.prepend(this.element); + + // Hamburger button + const hamburger = append(this.element, $('button.mobile-top-bar-button')); + hamburger.setAttribute('aria-label', localize('mobileTopBar.openSessions', "Open sessions")); + const hamburgerIcon = append(hamburger, $('span')); + hamburgerIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.menu)); + this._register(addDisposableListener(hamburger, EventType.CLICK, () => this._onDidClickHamburger.fire())); + + // Center slot: title and/or actions container (mutually exclusive) + const center = append(this.element, $('div.mobile-top-bar-center')); + + this.sessionTitleElement = append(center, $('button.mobile-session-title')); + this.sessionTitleElement.setAttribute('type', 'button'); + this.sessionTitleElement.textContent = localize('mobileTopBar.newSession', "New Session"); + this._register(addDisposableListener(this.sessionTitleElement, EventType.CLICK, () => this._onDidClickTitle.fire())); + + this.actionsContainer = append(center, $('div.mobile-top-bar-actions')); + + // New session button (+) + const newSession = append(this.element, $('button.mobile-top-bar-button')); + newSession.setAttribute('aria-label', localize('mobileTopBar.newSessionAria', "New session")); + const newSessionIcon = append(newSession, $('span')); + newSessionIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.plus)); + this._register(addDisposableListener(newSession, EventType.CLICK, () => this._onDidClickNewSession.fire())); + + // Keep the title in sync with the active session + this._register(autorun(reader => { + const session = this.sessionsManagementService.activeSession.read(reader); + const title = session?.title.read(reader); + this.sessionTitleElement.textContent = title || localize('mobileTopBar.newSession', "New Session"); + })); + + // Mount the center toolbar (host filter widget on web welcome, etc.) + const toolbar = this._register(instantiationService.createInstance(MenuWorkbenchToolBar, this.actionsContainer, Menus.MobileTitleBarCenter, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + telemetrySource: 'mobileTitlebar.center', + toolbarOptions: { primaryGroup: () => true }, + })); + + // Switch between title and toolbar based on whether a new (empty) + // chat session is active AND whether the toolbar has anything to + // show. The latter is important because on desktop/electron or + // when no agent hosts are configured the toolbar can be empty — + // in that case we keep the title visible. + const newChatKeySet = new Set([IsNewChatSessionContext.key]); + const updateCenterMode = () => { + const isNewChat = !!IsNewChatSessionContext.getValue(contextKeyService); + const hasActions = toolbar.getItemsLength() > 0; + this.element.classList.toggle('show-actions', isNewChat && hasActions); + }; + updateCenterMode(); + this._register(contextKeyService.onDidChangeContext(e => { + if (e.affectsSome(newChatKeySet)) { + updateCenterMode(); + } + })); + this._register(toolbar.onDidChangeMenuItems(() => updateCenterMode())); + } + + /** + * Explicitly set the title shown in the center slot. Called only when + * overriding the live session title (tests, placeholders). The live + * subscription will overwrite this on the next session change. + */ + setTitle(title: string): void { + this.sessionTitleElement.textContent = title; + } +} diff --git a/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts b/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts deleted file mode 100644 index 26a1aed9642d9..0000000000000 --- a/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts +++ /dev/null @@ -1,71 +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 './mobileChatShell.css'; -import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { $, addDisposableListener, append, EventType } from '../../../../base/browser/dom.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { localize } from '../../../../nls.js'; - -/** - * Mobile top bar component — a simple DOM element prepended to the - * workbench container on phone viewports. Replaces the desktop titlebar - * with a native-feeling mobile app bar. - * - * Layout: [hamburger] [session title] [+ new] - */ -export class MobileTopBar extends Disposable { - - readonly element: HTMLElement; - - private readonly sessionTitleElement: HTMLElement; - - private readonly _onDidClickHamburger = this._register(new Emitter()); - readonly onDidClickHamburger: Event = this._onDidClickHamburger.event; - - private readonly _onDidClickNewSession = this._register(new Emitter()); - readonly onDidClickNewSession: Event = this._onDidClickNewSession.event; - - private readonly _onDidClickTitle = this._register(new Emitter()); - readonly onDidClickTitle: Event = this._onDidClickTitle.event; - - constructor(parent: HTMLElement) { - super(); - - this.element = document.createElement('div'); - this.element.className = 'mobile-top-bar'; - - // Register DOM removal before appending so that any exception - // between this point and the end of the constructor still cleans - // up the element via disposal. - this._register(toDisposable(() => this.element.remove())); - parent.prepend(this.element); - - // Hamburger button - const hamburger = append(this.element, $('button.mobile-top-bar-button')); - hamburger.setAttribute('aria-label', 'Open sessions'); - const hamburgerIcon = append(hamburger, $('span')); - hamburgerIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.menu)); - this._register(addDisposableListener(hamburger, EventType.CLICK, () => this._onDidClickHamburger.fire())); - - // Session title - this.sessionTitleElement = append(this.element, $('div.mobile-session-title')); - this.sessionTitleElement.textContent = localize('mobileTopBar.newSession', "New Session"); - this._register(addDisposableListener(this.sessionTitleElement, EventType.CLICK, () => this._onDidClickTitle.fire())); - - // New session button (+) - const newSession = append(this.element, $('button.mobile-top-bar-button')); - newSession.setAttribute('aria-label', 'New session'); - const newSessionIcon = append(newSession, $('span')); - newSessionIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.plus)); - this._register(addDisposableListener(newSession, EventType.CLICK, () => this._onDidClickNewSession.fire())); - } - - setTitle(title: string): void { - this.sessionTitleElement.textContent = title; - } -} diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 3789111d77c67..de3c9c25d0f43 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -71,7 +71,7 @@ import { } from '../../workbench/common/notifications.js'; import { SessionsLayoutPolicy } from './layoutPolicy.js'; import { MobileNavigationStack } from './mobileNavigationStack.js'; -import { MobileTopBar } from './parts/mobile/mobileTopBar.js'; +import { MobileTitlebarPart } from './parts/mobile/mobileTitlebarPart.js'; import { autorun } from '../../base/common/observable.js'; import { ISessionsManagementService } from '../services/sessions/common/sessionsManagement.js'; @@ -240,7 +240,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic top = this.getPart(Parts.TITLEBAR_PART).maximumHeight; quickPickTop = top; } else if (this.mobileTopBarElement) { - // On phone layout the MobileTopBar replaces the titlebar + // On phone layout the MobileTitlebarPart replaces the titlebar top = this.mobileTopBarElement.offsetHeight; quickPickTop = top; } @@ -296,6 +296,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private paneCompositeService!: IPaneCompositePartService; private viewDescriptorService!: IViewDescriptorService; private sessionsManagementService!: ISessionsManagementService; + private instantiationService!: IInstantiationService; //#endregion @@ -472,7 +473,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Create mobile navigation after grid exists (so DOM order is correct) if (this.layoutPolicy.viewportClass.get() === 'phone') { - this.createMobileTopBar(); + this.createMobileTitlebar(); } // Workbench Management @@ -680,18 +681,18 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.parent.appendChild(this.mainContainer); } - private createMobileTopBar(): void { + private createMobileTitlebar(): void { this.mobileTopBarDisposables.clear(); - const mobileTopBar = this.mobileTopBarDisposables.add(new MobileTopBar(this.mainContainer)); - this.mobileTopBarElement = mobileTopBar.element; + const mobileTitlebar = this.mobileTopBarDisposables.add(this.instantiationService.createInstance(MobileTitlebarPart, this.mainContainer)); + this.mobileTopBarElement = mobileTitlebar.element; // Hamburger: toggle sidebar drawer overlay - this.mobileTopBarDisposables.add(mobileTopBar.onDidClickHamburger(() => { + this.mobileTopBarDisposables.add(mobileTitlebar.onDidClickHamburger(() => { this.toggleMobileSidebarDrawer(); })); // New session: open new chat view - this.mobileTopBarDisposables.add(mobileTopBar.onDidClickNewSession(() => { + this.mobileTopBarDisposables.add(mobileTitlebar.onDidClickNewSession(() => { this.sessionsManagementService.openNewSessionView(); })); } @@ -900,6 +901,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.paneCompositeService = accessor.get(IPaneCompositePartService); this.viewDescriptorService = accessor.get(IViewDescriptorService); this.sessionsManagementService = accessor.get(ISessionsManagementService); + this.instantiationService = accessor.get(IInstantiationService); accessor.get(ITitleService); // Register layout listeners @@ -1074,7 +1076,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic /** * Standard multi-part layout for all viewport classes. - * On phone, the titlebar is hidden via CSS and a MobileTopBar + * On phone, the titlebar is hidden via CSS and a MobileTitlebarPart * is prepended before the grid. Sidebar/panel/auxbar are hidden * in the grid via partVisibility defaults. */ @@ -1202,8 +1204,8 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // update part visibility and create/destroy mobile components if (previousClass !== undefined && previousClass !== currentClass) { if (currentClass === 'phone' && !this.mobileTopBarElement) { - this.createMobileTopBar(); - // Hide titlebar in grid on phone (replaced by MobileTopBar) + this.createMobileTitlebar(); + // Hide titlebar in grid on phone (replaced by MobileTitlebarPart) this.workbenchGrid.setViewVisible(this.titleBarPartView, false); // On phone, only chat is visible — hide everything else first const defaults = this.layoutPolicy.getPartVisibilityDefaults(); @@ -1428,7 +1430,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic isVisible(part: Parts, targetWindow?: Window): boolean { switch (part) { case Parts.TITLEBAR_PART: - // On phone layout the grid titlebar is hidden (replaced by MobileTopBar) + // On phone layout the grid titlebar is hidden (replaced by MobileTitlebarPart) return this.layoutPolicy.viewportClass.get() !== 'phone'; case Parts.SIDEBAR_PART: return this.partVisibility.sidebar; diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index b1bdfe7fef46c..c1382b3b248f1 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -8,6 +8,7 @@ import { IObservable } from '../../base/common/observable.js'; import { equals } from '../../base/common/objects.js'; import { RemoteAgentHostConnectionStatus } from '../../platform/agentHost/common/remoteAgentHostService.js'; import { ResolveSessionConfigResult, SessionConfigValueItem } from '../../platform/agentHost/common/state/protocol/commands.js'; +import { RootConfigState } from '../../platform/agentHost/common/state/protocol/state.js'; import { ISessionsProvider } from '../services/sessions/common/sessionsProvider.js'; /** @@ -66,6 +67,27 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider { getCreateSessionConfig(sessionId: string): Record | undefined; /** Clears dynamic configuration state for an abandoned new session. */ clearSessionConfig(sessionId: string): void; + + // -- Root (agent host) Config -- + + /** Fires when the root (agent host) configuration schema or values change. */ + readonly onDidChangeRootConfig: Event; + /** Returns the last-known root (agent host) configuration, or `undefined` if the host has not published any. */ + getRootConfig(): RootConfigState | undefined; + /** + * Sets one root configuration property. + * + * Optimistically updates local state and dispatches a + * `root/configChanged` action (non-replace) to the agent host. + */ + setRootConfigValue(property: string, value: unknown): Promise; + /** + * Replaces the full set of root configuration values atomically. + * + * Dispatches a single `root/configChanged` action with replace semantics. + * Unknown keys (no schema entry) are ignored. + */ + replaceRootConfig(values: Record): Promise; } export const LOCAL_AGENT_HOST_PROVIDER_ID = 'local-agent-host'; diff --git a/src/vs/sessions/contrib/agentHost/browser/agentHostSettings.contribution.ts b/src/vs/sessions/contrib/agentHost/browser/agentHostSettings.contribution.ts new file mode 100644 index 0000000000000..6c863a4281ba0 --- /dev/null +++ b/src/vs/sessions/contrib/agentHost/browser/agentHostSettings.contribution.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { ChatSessionProviderIdContext } from '../../../common/contextkeys.js'; +import { ISession } from '../../../services/sessions/common/session.js'; +import { SessionItemContextMenuId } from '../../sessions/browser/views/sessionsList.js'; +import { agentHostSettingsUri, AGENT_HOST_SETTINGS_SCHEME, AgentHostSettingsFileSystemProvider, AgentHostSettingsSchemaRegistrar } from './agentHostSettingsFileSystemProvider.js'; +import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsProvider.js'; + +/** + * Registers the {@link AgentHostSettingsFileSystemProvider} with the + * {@link IFileService} and contributes the "Open Host Settings" action. + */ +class AgentHostSettingsContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.agentHostSettingsContribution'; + + constructor( + @IFileService fileService: IFileService, + @IInstantiationService instantiationService: IInstantiationService, + @ILabelService labelService: ILabelService, + ) { + super(); + + const schemaRegistrar = this._register(instantiationService.createInstance(AgentHostSettingsSchemaRegistrar)); + const provider = this._register(instantiationService.createInstance(AgentHostSettingsFileSystemProvider, schemaRegistrar)); + this._register(fileService.registerProvider(AGENT_HOST_SETTINGS_SCHEME, provider)); + + this._register(labelService.registerFormatter({ + scheme: AGENT_HOST_SETTINGS_SCHEME, + formatting: { + label: localize('agentHostSettings.label', "Host Settings"), + separator: '/', + }, + })); + } +} + +registerWorkbenchContribution2(AgentHostSettingsContribution.ID, AgentHostSettingsContribution, WorkbenchPhase.AfterRestored); + +registerAction2(class OpenHostSettingsAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.openHostSettings', + title: localize2('openHostSettings', "Open Host Settings"), + menu: [{ + id: SessionItemContextMenuId, + group: '2_settings', + order: 2, + when: ContextKeyExpr.regex(ChatSessionProviderIdContext.key, ANY_AGENT_HOST_PROVIDER_RE), + }] + }); + } + async run(accessor: ServicesAccessor, context?: ISession | ISession[]): Promise { + const session = Array.isArray(context) ? context[0] : context; + if (!session) { + return; + } + const editorService = accessor.get(IEditorService); + const resource = agentHostSettingsUri(session.providerId); + await editorService.openEditor({ resource, options: { pinned: true } }); + } +}); diff --git a/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsFileSystemProvider.ts b/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsFileSystemProvider.ts new file mode 100644 index 0000000000000..75127ddc4622d --- /dev/null +++ b/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsFileSystemProvider.ts @@ -0,0 +1,157 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IJSONSchema } from '../../../../base/common/jsonSchema.js'; +import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { RootConfigState } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import { IAgentHostSessionsProvider } from '../../../common/agentHostSessionsProvider.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { + AbstractAgentHostConfigFileSystemProvider, + AbstractAgentHostConfigSchemaRegistrar, + AgentHostConfigPropertyFilter, + buildAgentHostConfigJsonSchema, + IAgentHostConfigLike, + IAgentHostSettingsContext, + IAgentHostSettingsLocale, + serializeAgentHostConfigDocument, +} from './agentHostSettingsShared.js'; + +/** Scheme for the synthetic agent-host settings files. */ +export const AGENT_HOST_SETTINGS_SCHEME = 'agent-host-settings'; + +/** + * Build the URI used to open the settings file for an agent host provider. + * + * URI shape: `agent-host-settings://{providerId}/settings.jsonc` + */ +export function agentHostSettingsUri(providerId: string): URI { + return URI.from({ + scheme: AGENT_HOST_SETTINGS_SCHEME, + authority: providerId, + path: `/settings.jsonc`, + }); +} + +function parseHostSettingsUri(uri: URI): IAgentHostSettingsContext | undefined { + if (uri.scheme !== AGENT_HOST_SETTINGS_SCHEME) { + return undefined; + } + const providerId = uri.authority; + if (!providerId) { + return undefined; + } + return { providerId }; +} + +/** Root (agent host) config exposes no per-property mutability flags — all props are editable. */ +const hostSettingsPropertyFilter: AgentHostConfigPropertyFilter = () => true; + +const hostSettingsLocale: IAgentHostSettingsLocale = { + get header() { return localize('agentHostSettings.header', "Agent host settings."); }, + get saveHint() { return localize('agentHostSettings.saveHint', "Edit values below and save to apply. Unknown properties are ignored."); }, + get parseError() { return localize('agentHostSettings.parseError', "Failed to parse agent host settings as JSON."); }, + get notObject() { return localize('agentHostSettings.notObject', "Agent host settings must be a JSON object."); }, +}; + +/** + * Serialize the root config values for an agent host provider into a + * commented, pretty-printed JSON document. + */ +export function serializeHostSettings(provider: IAgentHostSessionsProvider): string { + return serializeAgentHostConfigDocument(provider.getRootConfig(), hostSettingsPropertyFilter, hostSettingsLocale); +} + +/** + * Build a JSON schema describing the root config of an agent host provider. + */ +export function buildHostSettingsJsonSchema(config: RootConfigState): IJSONSchema { + return buildAgentHostConfigJsonSchema(config, hostSettingsPropertyFilter); +} + +/** + * Filesystem provider serving synthetic JSONC documents representing the + * root (agent host) configuration values of agent-host providers. + */ +export class AgentHostSettingsFileSystemProvider extends AbstractAgentHostConfigFileSystemProvider { + + protected readonly _schemeLabel = AGENT_HOST_SETTINGS_SCHEME; + protected readonly _traceTag = 'AgentHostSettings'; + protected readonly _locale = hostSettingsLocale; + + constructor( + private readonly _schemaRegistrar: AgentHostSettingsSchemaRegistrar, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + @ILogService logService: ILogService, + ) { + super(sessionsProvidersService, logService); + } + + protected _parseUri(resource: URI): IAgentHostSettingsContext | undefined { + return parseHostSettingsUri(resource); + } + + protected _serialize(provider: IAgentHostSessionsProvider): string { + return serializeHostSettings(provider); + } + + protected _watchChanges(provider: IAgentHostSessionsProvider, _ctx: IAgentHostSettingsContext, fire: () => void): IDisposable { + return provider.onDidChangeRootConfig(() => fire()); + } + + protected _ensureSchemaRegistered(provider: IAgentHostSessionsProvider): void { + this._schemaRegistrar.ensureRegistered(provider, provider); + } + + protected _hasConfig(provider: IAgentHostSessionsProvider): boolean { + return provider.getRootConfig() !== undefined; + } + + protected _replaceConfig(provider: IAgentHostSessionsProvider, _ctx: IAgentHostSettingsContext, values: Record): Promise { + return provider.replaceRootConfig(values); + } + + protected _describeForTrace(ctx: IAgentHostSettingsContext): string { + return `provider ${ctx.providerId}`; + } +} + +/** + * Keeps per-provider JSON schemas registered so editors of the synthetic + * `agent-host-settings://…` files get completions, hover, and validation. + */ +export class AgentHostSettingsSchemaRegistrar extends AbstractAgentHostConfigSchemaRegistrar { + + protected _propertyFilter(): AgentHostConfigPropertyFilter { + return hostSettingsPropertyFilter; + } + + protected _settingsUri(provider: IAgentHostSessionsProvider): string { + return agentHostSettingsUri(provider.id).toString(); + } + + protected _schemaId(provider: IAgentHostSessionsProvider): string { + return `vscode://schemas/agent-host-settings/${provider.id}.jsonc`; + } + + protected _getConfig(_provider: IAgentHostSessionsProvider, target: IAgentHostSessionsProvider): IAgentHostConfigLike | undefined { + return target.getRootConfig(); + } + + protected _targetsForProvider(provider: IAgentHostSessionsProvider): readonly IAgentHostSessionsProvider[] { + return [provider]; + } + + protected _observeProvider( + provider: IAgentHostSessionsProvider, + onChanged: (target: IAgentHostSessionsProvider) => void, + _onRemoved: (target: IAgentHostSessionsProvider) => void, + ): IDisposable { + return provider.onDidChangeRootConfig(() => onChanged(provider)); + } +} diff --git a/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsShared.ts b/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsShared.ts new file mode 100644 index 0000000000000..e29f5b904d136 --- /dev/null +++ b/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsShared.ts @@ -0,0 +1,512 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { parse, ParseError } from '../../../../base/common/json.js'; +import { IJSONSchema } from '../../../../base/common/jsonSchema.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { + createFileSystemProviderError, + FileChangeType, + FilePermission, + FileSystemProviderCapabilities, + FileSystemProviderErrorCode, + FileType, + IFileChange, + IFileDeleteOptions, + IFileOverwriteOptions, + IFileSystemProviderWithFileReadWriteCapability, + IFileWriteOptions, + IStat, + IWatchOptions, +} from '../../../../platform/files/common/files.js'; +import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { ConfigPropertySchema, ConfigSchema } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; +import { ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js'; + +// ============================================================================ +// Shared helpers for agent-host config settings filesystem providers. +// +// Both the per-session (`agent-session-settings://...`) and the per-host +// (`agent-host-settings://...`) synthetic settings editors follow the same +// shape: they render a provider's config schema as a JSONC document, watch +// for config changes, and round-trip user edits through a +// `replace*Config` API. This module factors out that shared plumbing. +// ============================================================================ + +/** + * Minimal config shape shared by session ({@link ResolveSessionConfigResult}) + * and root ({@link RootConfigState}) configuration. + */ +export interface IAgentHostConfigLike { + readonly schema: ConfigSchema; + readonly values: Record; +} + +/** + * Filter applied to schema properties to decide which ones surface in the + * editable document (and in the derived JSON schema). + * + * For session settings this filters to `sessionMutable && !readOnly`. For + * host settings all properties are editable, so the filter is a constant + * `true`. + */ +export type AgentHostConfigPropertyFilter = (key: string, schema: ConfigPropertySchema) => boolean; + +/** + * Localized strings used to decorate the serialized JSONC document. + */ +export interface IAgentHostSettingsLocale { + /** Header comment line describing the document. */ + readonly header: string; + /** Secondary hint comment describing save semantics. */ + readonly saveHint: string; + /** Error message thrown when the document fails to parse as JSONC. */ + readonly parseError: string; + /** Error message thrown when the parsed document is not a JSON object. */ + readonly notObject: string; +} + +/** + * Convert a config property schema (protocol shape) into an + * {@link IJSONSchema} suitable for registration with the JSON language + * service. + */ +export function convertPropertySchema(schema: ConfigPropertySchema): IJSONSchema { + const out: IJSONSchema = { + type: schema.type, + title: schema.title, + description: schema.description, + default: schema.default, + }; + if (schema.enum && schema.enum.length > 0) { + out.enum = [...schema.enum]; + if (schema.enumDescriptions && schema.enumDescriptions.length > 0) { + out.enumDescriptions = [...schema.enumDescriptions]; + } + } + if (schema.type === 'array' && schema.items) { + out.items = convertPropertySchema(schema.items); + } + if (schema.type === 'object' && schema.properties) { + const properties: Record = {}; + for (const [key, value] of Object.entries(schema.properties)) { + properties[key] = convertPropertySchema(value); + } + out.properties = properties; + if (schema.required && schema.required.length > 0) { + out.required = [...schema.required]; + } + } + return out; +} + +/** + * Build a JSON schema describing the filtered properties of an agent-host + * config. Properties that pass {@link filter} are included; others are + * dropped. `required` entries are carried through when the referenced + * property survives the filter. + */ +export function buildAgentHostConfigJsonSchema(config: IAgentHostConfigLike, filter: AgentHostConfigPropertyFilter): IJSONSchema { + const properties: Record = {}; + const required: string[] = []; + for (const [key, schema] of Object.entries(config.schema.properties)) { + if (!filter(key, schema)) { + continue; + } + properties[key] = convertPropertySchema(schema); + if (config.schema.required?.includes(key)) { + required.push(key); + } + } + const result: IJSONSchema = { + type: 'object', + properties, + additionalProperties: true, + }; + if (required.length > 0) { + result.required = required; + } + return result; +} + +function buildHeaderComment( + locale: IAgentHostSettingsLocale, + props: readonly (readonly [string, ConfigPropertySchema])[] | undefined, +): string { + const lines: string[] = []; + lines.push(`// ${locale.header}`); + lines.push(`// ${locale.saveHint}`); + if (props && props.length > 0) { + lines.push('//'); + for (const [key, schema] of props) { + const suffix = schema.enum && schema.enum.length > 0 ? ` (${schema.enum.join(' | ')})` : ''; + const title = schema.title || key; + lines.push(`// ${key}: ${title}${suffix}`); + if (schema.description) { + lines.push(`// ${schema.description}`); + } + } + } + lines.push(''); + return lines.join('\n'); +} + +/** + * Serialize the filtered config values into a commented, pretty-printed + * JSONC document. + */ +export function serializeAgentHostConfigDocument( + config: IAgentHostConfigLike | undefined, + filter: AgentHostConfigPropertyFilter, + locale: IAgentHostSettingsLocale, +): string { + if (!config) { + return `${buildHeaderComment(locale, undefined)}{}\n`; + } + + const editableProps = Object.entries(config.schema.properties).filter(([key, schema]) => filter(key, schema)); + const values: Record = {}; + for (const [key] of editableProps) { + if (config.values[key] !== undefined) { + values[key] = config.values[key]; + } + } + + return `${buildHeaderComment(locale, editableProps)}${JSON.stringify(values, null, 2)}\n`; +} + +// ============================================================================ +// AbstractAgentHostConfigFileSystemProvider +// ============================================================================ + +/** + * Base context shared by all settings filesystem providers. Subclasses + * extend with any additional state they need (e.g. a sessionId). + */ +export interface IAgentHostSettingsContext { + readonly providerId: string; +} + +/** + * Abstract filesystem provider backing the synthetic agent-host settings + * JSONC editors. Subclasses supply scheme-specific URI parsing, + * config-fetching, change-watching, and replace-dispatch hooks; the base + * handles the boilerplate (`stat`/`readFile`/`writeFile`/error shapes). + */ +export abstract class AbstractAgentHostConfigFileSystemProvider extends Disposable implements IFileSystemProviderWithFileReadWriteCapability { + + readonly capabilities = FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.PathCaseSensitive; + + private readonly _onDidChangeCapabilities = this._register(new Emitter()); + readonly onDidChangeCapabilities = this._onDidChangeCapabilities.event; + + protected readonly _onDidChangeFile = this._register(new Emitter()); + readonly onDidChangeFile = this._onDidChangeFile.event; + + constructor( + @ISessionsProvidersService protected readonly _sessionsProvidersService: ISessionsProvidersService, + @ILogService protected readonly _logService: ILogService, + ) { + super(); + } + + // ---- Subclass hooks ----------------------------------------------------- + + /** URI scheme label used in error messages (e.g. `'agent-session-settings'`). */ + protected abstract readonly _schemeLabel: string; + + /** Log trace-tag (e.g. `'AgentSessionSettings'`). */ + protected abstract readonly _traceTag: string; + + /** Localized strings for the JSONC document and write-path errors. */ + protected abstract readonly _locale: IAgentHostSettingsLocale; + + /** Parse a URI of the subclass's scheme into a typed context. */ + protected abstract _parseUri(resource: URI): TContext | undefined; + + /** Render the current config for a context as a JSONC document. */ + protected abstract _serialize(provider: IAgentHostSessionsProvider, ctx: TContext): string; + + /** + * Subscribe for changes relevant to the given context. When a change is + * detected the subclass should invoke {@link fire}. + */ + protected abstract _watchChanges(provider: IAgentHostSessionsProvider, ctx: TContext, fire: () => void): IDisposable; + + /** Register / refresh the JSON schema for the given context. */ + protected abstract _ensureSchemaRegistered(provider: IAgentHostSessionsProvider, ctx: TContext): void; + + /** Whether the backing config is currently available. */ + protected abstract _hasConfig(provider: IAgentHostSessionsProvider, ctx: TContext): boolean; + + /** Dispatch a replace write of the parsed JSONC document. */ + protected abstract _replaceConfig(provider: IAgentHostSessionsProvider, ctx: TContext, values: Record): Promise; + + /** + * Build a short human-readable description of `ctx` for log messages + * when a write is ignored due to missing config (e.g. a session id). + */ + protected abstract _describeForTrace(ctx: TContext): string; + + // ---- IFileSystemProvider ------------------------------------------------ + + watch(resource: URI, _opts: IWatchOptions): IDisposable { + const parsed = this._parseUri(resource); + if (!parsed) { + return Disposable.None; + } + const provider = this._lookupProvider(parsed.providerId); + if (!provider) { + return Disposable.None; + } + return this._watchChanges(provider, parsed, () => { + this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]); + }); + } + + async stat(resource: URI): Promise { + const { provider, ctx } = this._resolveOrThrow(resource); + const content = this._serialize(provider, ctx); + return { + type: FileType.File, + ctime: 0, + mtime: 0, + size: VSBuffer.fromString(content).byteLength, + permissions: 0 as FilePermission, + }; + } + + async readdir(): Promise<[string, FileType][]> { + throw createFileSystemProviderError('readdir not supported', FileSystemProviderErrorCode.NoPermissions); + } + + async readFile(resource: URI): Promise { + const { provider, ctx } = this._resolveOrThrow(resource); + const content = this._serialize(provider, ctx); + + // Register the JSON schema on demand the first time a settings file + // is read. The subclass keeps it in sync from then on. + this._ensureSchemaRegistered(provider, ctx); + + return VSBuffer.fromString(content).buffer; + } + + async writeFile(resource: URI, content: Uint8Array, _opts: IFileWriteOptions): Promise { + const { provider, ctx } = this._resolveOrThrow(resource); + + const text = VSBuffer.wrap(content).toString(); + const errors: ParseError[] = []; + const parsed_json = parse(text, errors); + if (errors.length > 0) { + throw createFileSystemProviderError(this._locale.parseError, FileSystemProviderErrorCode.Unavailable); + } + if (parsed_json === null || typeof parsed_json !== 'object' || Array.isArray(parsed_json)) { + throw createFileSystemProviderError(this._locale.notObject, FileSystemProviderErrorCode.Unavailable); + } + + if (!this._hasConfig(provider, ctx)) { + this._logService.trace(`[${this._traceTag}] No config state for ${this._describeForTrace(ctx)}; ignoring write.`); + this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]); + return; + } + + await this._replaceConfig(provider, ctx, parsed_json as Record); + + this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]); + } + + async mkdir(): Promise { + throw createFileSystemProviderError('mkdir not supported', FileSystemProviderErrorCode.NoPermissions); + } + + async delete(_resource: URI, _opts: IFileDeleteOptions): Promise { + throw createFileSystemProviderError('delete not supported', FileSystemProviderErrorCode.NoPermissions); + } + + async rename(_from: URI, _to: URI, _opts: IFileOverwriteOptions): Promise { + throw createFileSystemProviderError('rename not supported', FileSystemProviderErrorCode.NoPermissions); + } + + // ---- Helpers ------------------------------------------------------------ + + protected _lookupProvider(providerId: string): IAgentHostSessionsProvider | undefined { + const provider = this._sessionsProvidersService.getProvider(providerId); + if (!provider || !isAgentHostProvider(provider)) { + return undefined; + } + return provider; + } + + private _resolveOrThrow(resource: URI): { provider: IAgentHostSessionsProvider; ctx: TContext } { + const ctx = this._parseUri(resource); + if (!ctx) { + throw createFileSystemProviderError(`Invalid ${this._schemeLabel} URI: ${resource.toString()}`, FileSystemProviderErrorCode.FileNotFound); + } + const provider = this._lookupProvider(ctx.providerId); + if (!provider) { + throw createFileSystemProviderError(`Unknown agent host provider: ${ctx.providerId}`, FileSystemProviderErrorCode.FileNotFound); + } + return { provider, ctx }; + } +} + +// ============================================================================ +// AbstractAgentHostConfigSchemaRegistrar +// ============================================================================ + +/** + * Abstract base for the schema registrars that keep JSON schemas registered + * on the {@link IJSONContributionRegistry} for the synthetic settings + * editors. Subclasses plumb per-provider subscriptions and the target-type + * that identifies what a schema belongs to (an `ISession` for the session + * editor, an `IAgentHostSessionsProvider` for the host editor). + * + * Registration is lazy — {@link ensureRegistered} is called by the + * filesystem provider when a settings file is first read. Once registered, + * the schema is kept in sync via the subclass's change subscription until + * the provider is removed. + */ +export abstract class AbstractAgentHostConfigSchemaRegistrar extends Disposable { + + private readonly _schemaRegistry = Registry.as(JSONExtensions.JSONContribution); + + /** Per-provider subscriptions. */ + private readonly _providerSubscriptions = this._register(new DisposableMap()); + + /** Per-target registered-schema disposables, keyed by the settings URI string. */ + private readonly _targetSchemas = this._register(new DisposableMap()); + + /** + * Tracks the {@link ConfigSchema} identity last used to register a schema + * for a given settings URI so we can skip re-registration when only + * values have changed. + */ + private readonly _lastSchemaIdentity = new Map(); + + constructor( + @ISessionsProvidersService protected readonly _sessionsProvidersService: ISessionsProvidersService, + ) { + super(); + + for (const provider of this._sessionsProvidersService.getProviders()) { + this._onProviderAdded(provider); + } + this._register(this._sessionsProvidersService.onDidChangeProviders(e => { + for (const provider of e.added) { + this._onProviderAdded(provider); + } + for (const provider of e.removed) { + this._providerSubscriptions.deleteAndDispose(provider.id); + } + })); + } + + // ---- Subclass hooks ----------------------------------------------------- + + /** Stringified URI identifying the settings document for a target. */ + protected abstract _settingsUri(target: TTarget): string; + + /** `vscode://schemas/...` schema id used for JSON language service registration. */ + protected abstract _schemaId(target: TTarget): string; + + /** Fetch the backing config for a target. Returns `undefined` when none yet. */ + protected abstract _getConfig(provider: IAgentHostSessionsProvider, target: TTarget): IAgentHostConfigLike | undefined; + + /** Filter applied to schema properties when building the JSON schema. */ + protected abstract _propertyFilter(): AgentHostConfigPropertyFilter; + + /** Enumerate the targets currently tracked on a provider (used for cleanup). */ + protected abstract _targetsForProvider(provider: IAgentHostSessionsProvider): readonly TTarget[]; + + /** + * Subscribe to change signals from {@link provider}. The subclass should + * invoke {@link onChanged} when a tracked target's config changes and + * {@link onRemoved} when a tracked target disappears. + */ + protected abstract _observeProvider( + provider: IAgentHostSessionsProvider, + onChanged: (target: TTarget) => void, + onRemoved: (target: TTarget) => void, + ): IDisposable; + + // ---- Public API --------------------------------------------------------- + + /** + * Ensures a JSON schema is registered for the given target. Safe to + * call repeatedly; a no-op when the cached schema identity matches. + */ + ensureRegistered(provider: IAgentHostSessionsProvider, target: TTarget): void { + this._refreshSchema(provider, target); + } + + // ---- Internal ----------------------------------------------------------- + + private _onProviderAdded(provider: ISessionsProvider): void { + if (!isAgentHostProvider(provider)) { + return; + } + const store = new DisposableStore(); + + store.add(this._observeProvider( + provider, + target => { + // Only refresh if we already have a registration; otherwise the + // next `readFile` will pick up the latest schema on demand. + if (!this._lastSchemaIdentity.has(this._settingsUri(target))) { + return; + } + this._refreshSchema(provider, target); + }, + target => this._disposeSchemaForTarget(target), + )); + + // On provider disposal, drop all schemas registered for this provider. + store.add(toDisposable(() => { + for (const target of this._targetsForProvider(provider)) { + this._disposeSchemaForTarget(target); + } + })); + + this._providerSubscriptions.set(provider.id, store); + } + + private _refreshSchema(provider: IAgentHostSessionsProvider, target: TTarget): void { + const config = this._getConfig(provider, target); + if (!config) { + return; + } + const settingsUri = this._settingsUri(target); + const identity = config.schema; + if (this._lastSchemaIdentity.get(settingsUri) === identity) { + return; + } + + const schema = buildAgentHostConfigJsonSchema(config, this._propertyFilter()); + const schemaId = this._schemaId(target); + + // Dispose any prior registration first, otherwise the old cleanup + // disposable would delete the freshly registered schema. + this._targetSchemas.deleteAndDispose(settingsUri); + + const store = new DisposableStore(); + this._schemaRegistry.registerSchema(schemaId, schema, store); + store.add(this._schemaRegistry.registerSchemaAssociation(schemaId, settingsUri)); + store.add(toDisposable(() => this._lastSchemaIdentity.delete(settingsUri))); + + this._targetSchemas.set(settingsUri, store); + this._lastSchemaIdentity.set(settingsUri, identity); + } + + private _disposeSchemaForTarget(target: TTarget): void { + this._targetSchemas.deleteAndDispose(this._settingsUri(target)); + } +} diff --git a/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts index cd0caf3c3ace8..19c56865fbb4f 100644 --- a/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts @@ -3,37 +3,26 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { VSBuffer } from '../../../../base/common/buffer.js'; -import { Emitter } from '../../../../base/common/event.js'; -import { parse, ParseError } from '../../../../base/common/json.js'; import { IJSONSchema } from '../../../../base/common/jsonSchema.js'; -import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; -import { - createFileSystemProviderError, - FileChangeType, - FilePermission, - FileSystemProviderCapabilities, - FileSystemProviderErrorCode, - FileType, - IFileChange, - IFileDeleteOptions, - IFileOverwriteOptions, - IFileSystemProviderWithFileReadWriteCapability, - IFileWriteOptions, - IStat, - IWatchOptions, -} from '../../../../platform/files/common/files.js'; -import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { Registry } from '../../../../platform/registry/common/platform.js'; -import { SessionConfigPropertySchema, SessionConfigSchema } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { ResolveSessionConfigResult } from '../../../../platform/agentHost/common/state/protocol/commands.js'; -import { ISession, toSessionId } from '../../../services/sessions/common/session.js'; +import { SessionConfigPropertySchema } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import { IAgentHostSessionsProvider } from '../../../common/agentHostSessionsProvider.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; -import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; -import { ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js'; +import { ISession, toSessionId } from '../../../services/sessions/common/session.js'; +import { + AbstractAgentHostConfigFileSystemProvider, + AbstractAgentHostConfigSchemaRegistrar, + AgentHostConfigPropertyFilter, + buildAgentHostConfigJsonSchema, + IAgentHostConfigLike, + IAgentHostSettingsContext, + IAgentHostSettingsLocale, + serializeAgentHostConfigDocument, +} from './agentHostSettingsShared.js'; /** Scheme for the synthetic agent-host session settings files. */ export const AGENT_SESSION_SETTINGS_SCHEME = 'agent-session-settings'; @@ -44,8 +33,8 @@ export const AGENT_SESSION_SETTINGS_SCHEME = 'agent-session-settings'; * URI shape: `agent-session-settings://{providerId}/{resourceScheme}{resourcePath}.jsonc` * * - `authority` = {@link ISession.providerId} (e.g. `local-agent-host`, `agenthost-`) - * - path encodes the session's resource scheme and path so {@link parseSettingsUri} - * can reconstruct the full {@link ISession.sessionId} via {@link toSessionId} + * - path encodes the session's resource scheme and path so the URI can be + * parsed back into an {@link ISession.sessionId} via {@link toSessionId} * without having to look the session up on the provider. */ export function agentSessionSettingsUri(session: ISession): URI { @@ -57,13 +46,12 @@ export function agentSessionSettingsUri(session: ISession): URI { }); } -interface IParsedSettingsUri { - readonly providerId: string; +interface ISessionSettingsContext extends IAgentHostSettingsContext { /** Reconstructed {@link ISession.sessionId}. */ readonly sessionId: string; } -function parseSettingsUri(uri: URI): IParsedSettingsUri | undefined { +function parseSessionSettingsUri(uri: URI): ISessionSettingsContext | undefined { if (uri.scheme !== AGENT_SESSION_SETTINGS_SCHEME) { return undefined; } @@ -90,434 +78,148 @@ function parseSettingsUri(uri: URI): IParsedSettingsUri | undefined { return { providerId, sessionId: toSessionId(providerId, resource) }; } +/** + * Property filter: only session-mutable, non-read-only properties are + * editable. Read-only / non-mutable properties (e.g. `isolation`, `branch`) + * are preserved in the underlying config and round-tripped on write — they + * just aren't surfaced for editing. + */ +const sessionSettingsPropertyFilter: AgentHostConfigPropertyFilter = (_key, schema) => { + const s = schema as SessionConfigPropertySchema; + return s.sessionMutable === true && s.readOnly !== true; +}; + +const sessionSettingsLocale: IAgentHostSettingsLocale = { + get header() { return localize('agentSessionSettings.header', "Session settings for this agent host session."); }, + get saveHint() { return localize('agentSessionSettings.saveHint', "Edit values below and save to apply. Unknown or non-mutable properties are ignored."); }, + get parseError() { return localize('agentSessionSettings.parseError', "Failed to parse agent session settings as JSON."); }, + get notObject() { return localize('agentSessionSettings.notObject', "Agent session settings must be a JSON object."); }, +}; + /** * Serialize the session-mutable config values for a session into a * commented, pretty-printed JSON document. */ export function serializeSessionSettings(provider: IAgentHostSessionsProvider, sessionId: string): string { - const config = provider.getSessionConfig(sessionId); - if (!config) { - return `${headerComment(undefined)}{}\n`; - } - - // Only include session-mutable, non-readOnly properties in the editable - // document. Read-only / non-mutable properties (e.g. `isolation`, `branch`) - // are preserved in the underlying config and round-tripped on write — - // they just aren't surfaced for editing. - const mutableProps = Object.entries(config.schema.properties).filter(([, schema]) => schema.sessionMutable && !schema.readOnly); - const values: Record = {}; - for (const [key] of mutableProps) { - if (config.values[key] !== undefined) { - values[key] = config.values[key]; - } - } - - return `${headerComment(mutableProps)}${JSON.stringify(values, null, 2)}\n`; + return serializeAgentHostConfigDocument(provider.getSessionConfig(sessionId), sessionSettingsPropertyFilter, sessionSettingsLocale); } -function headerComment(props: readonly (readonly [string, { readonly title: string; readonly description?: string; readonly enum?: readonly string[] }])[] | undefined): string { - const lines: string[] = []; - lines.push(`// ${localize('agentSessionSettings.header', "Session settings for this agent host session.")}`); - lines.push(`// ${localize('agentSessionSettings.saveHint', "Edit values below and save to apply. Unknown or non-mutable properties are ignored.")}`); - if (props && props.length > 0) { - lines.push('//'); - for (const [key, schema] of props) { - const suffix = schema.enum && schema.enum.length > 0 ? ` (${schema.enum.join(' | ')})` : ''; - const title = schema.title || key; - lines.push(`// ${key}: ${title}${suffix}`); - if (schema.description) { - lines.push(`// ${schema.description}`); - } - } - } - lines.push(''); - return lines.join('\n'); +/** + * Build a JSON schema describing the editable session-mutable, non-readOnly + * properties of an agent-host session config. The filter mirrors the one + * used by {@link serializeSessionSettings} so validation matches the file + * contents produced by this provider. + */ +export function buildSessionSettingsJsonSchema(config: ResolveSessionConfigResult): IJSONSchema { + return buildAgentHostConfigJsonSchema(config, sessionSettingsPropertyFilter); } /** * Filesystem provider serving synthetic JSONC documents that represent the * session-mutable config values of agent-host sessions. - * - * Reads render `IAgentHostSessionsProvider.getSessionConfig()` as pretty - * JSONC. Writes parse the document with the JSONC parser and push the user's - * full editable view to `replaceSessionConfig`, which atomically replaces - * user-editable values while preserving non-mutable / readOnly properties - * (e.g. `isolation`, `branch`) server-side. */ -export class AgentSessionSettingsFileSystemProvider extends Disposable implements IFileSystemProviderWithFileReadWriteCapability { - - readonly capabilities = FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.PathCaseSensitive; - - private readonly _onDidChangeCapabilities = this._register(new Emitter()); - readonly onDidChangeCapabilities = this._onDidChangeCapabilities.event; +export class AgentSessionSettingsFileSystemProvider extends AbstractAgentHostConfigFileSystemProvider { - private readonly _onDidChangeFile = this._register(new Emitter()); - readonly onDidChangeFile = this._onDidChangeFile.event; + protected readonly _schemeLabel = AGENT_SESSION_SETTINGS_SCHEME; + protected readonly _traceTag = 'AgentSessionSettings'; + protected readonly _locale = sessionSettingsLocale; constructor( private readonly _schemaRegistrar: AgentSessionSettingsSchemaRegistrar, - @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, - @ILogService private readonly _logService: ILogService, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + @ILogService logService: ILogService, ) { - super(); + super(sessionsProvidersService, logService); } - watch(resource: URI, _opts: IWatchOptions): IDisposable { - // The underlying provider fires `onDidChangeSessionConfig` with a sessionId; - // forward those into `onDidChangeFile` for the watched resource. - const parsed = parseSettingsUri(resource); - if (!parsed) { - return Disposable.None; - } - const provider = this._lookupProvider(parsed.providerId); - if (!provider) { - return Disposable.None; - } - return provider.onDidChangeSessionConfig(changedSessionId => { - if (changedSessionId === parsed.sessionId) { - this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]); - } - }); + protected _parseUri(resource: URI): ISessionSettingsContext | undefined { + return parseSessionSettingsUri(resource); } - async stat(resource: URI): Promise { - const parsed = parseSettingsUri(resource); - if (!parsed) { - throw createFileSystemProviderError(`Invalid agent-session-settings URI: ${resource.toString()}`, FileSystemProviderErrorCode.FileNotFound); - } - const { provider, sessionId } = this._resolve(parsed); - const content = serializeSessionSettings(provider, sessionId); - return { - type: FileType.File, - ctime: 0, - mtime: 0, - size: VSBuffer.fromString(content).byteLength, - permissions: 0 as FilePermission, - }; + protected _serialize(provider: IAgentHostSessionsProvider, ctx: ISessionSettingsContext): string { + return serializeSessionSettings(provider, ctx.sessionId); } - async readdir(): Promise<[string, FileType][]> { - throw createFileSystemProviderError('readdir not supported', FileSystemProviderErrorCode.NoPermissions); + protected _watchChanges(provider: IAgentHostSessionsProvider, ctx: ISessionSettingsContext, fire: () => void): IDisposable { + return provider.onDidChangeSessionConfig(changedSessionId => { + if (changedSessionId === ctx.sessionId) { + fire(); + } + }); } - async readFile(resource: URI): Promise { - const parsed = parseSettingsUri(resource); - if (!parsed) { - throw createFileSystemProviderError(`Invalid agent-session-settings URI: ${resource.toString()}`, FileSystemProviderErrorCode.FileNotFound); - } - const { provider, sessionId } = this._resolve(parsed); - const content = serializeSessionSettings(provider, sessionId); - - // Register the JSON schema on demand the first time a settings file - // is read. The registrar keeps it in sync from then on. - const session = provider.getSessions().find(s => s.sessionId === sessionId); + protected _ensureSchemaRegistered(provider: IAgentHostSessionsProvider, ctx: ISessionSettingsContext): void { + const session = provider.getSessions().find(s => s.sessionId === ctx.sessionId); if (session) { - this._schemaRegistrar.ensureRegistered(session); - } - - return VSBuffer.fromString(content).buffer; - } - - async writeFile(resource: URI, content: Uint8Array, _opts: IFileWriteOptions): Promise { - const parsed = parseSettingsUri(resource); - if (!parsed) { - throw createFileSystemProviderError(`Invalid agent-session-settings URI: ${resource.toString()}`, FileSystemProviderErrorCode.FileNotFound); - } - const { provider, sessionId } = this._resolve(parsed); - - const text = VSBuffer.wrap(content).toString(); - const errors: ParseError[] = []; - const parsed_json = parse(text, errors); - if (errors.length > 0) { - throw createFileSystemProviderError( - localize('agentSessionSettings.parseError', "Failed to parse agent session settings as JSON."), - FileSystemProviderErrorCode.Unavailable, - ); - } - if (parsed_json === null || typeof parsed_json !== 'object' || Array.isArray(parsed_json)) { - throw createFileSystemProviderError( - localize('agentSessionSettings.notObject', "Agent session settings must be a JSON object."), - FileSystemProviderErrorCode.Unavailable, - ); + this._schemaRegistrar.ensureRegistered(provider, session); } - - const currentConfig = provider.getSessionConfig(sessionId); - if (!currentConfig) { - this._logService.trace(`[AgentSessionSettings] No config state for session ${sessionId}; ignoring write.`); - this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]); - return; - } - - // The input is the user's full view of editable values. Dispatch as a - // replace — `replaceSessionConfig` guarantees non-editable properties - // (non-mutable or readOnly) are preserved regardless of what we send, - // and unknown keys are ignored. This means: - // - Re-asserted editable keys overwrite the current value. - // - Omitted editable keys are unset (supports clearing via deletion). - // - Non-editable keys (e.g. `isolation`, `branch`) are round-tripped - // server-side even though we never read or write them here. - await provider.replaceSessionConfig(sessionId, parsed_json as Record); - - this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]); - } - - async mkdir(): Promise { - throw createFileSystemProviderError('mkdir not supported', FileSystemProviderErrorCode.NoPermissions); } - async delete(_resource: URI, _opts: IFileDeleteOptions): Promise { - throw createFileSystemProviderError('delete not supported', FileSystemProviderErrorCode.NoPermissions); + protected _hasConfig(provider: IAgentHostSessionsProvider, ctx: ISessionSettingsContext): boolean { + return provider.getSessionConfig(ctx.sessionId) !== undefined; } - async rename(_from: URI, _to: URI, _opts: IFileOverwriteOptions): Promise { - throw createFileSystemProviderError('rename not supported', FileSystemProviderErrorCode.NoPermissions); - } - - // ---- Helpers ------------------------------------------------------------ - - private _lookupProvider(providerId: string): IAgentHostSessionsProvider | undefined { - const provider = this._sessionsProvidersService.getProvider(providerId); - if (!provider || !isAgentHostProvider(provider)) { - return undefined; - } - return provider; + // The input is the user's full view of editable values. Dispatch as a + // replace — `replaceSessionConfig` guarantees non-editable properties + // (non-mutable or readOnly) are preserved regardless of what we send, + // and unknown keys are ignored. + protected _replaceConfig(provider: IAgentHostSessionsProvider, ctx: ISessionSettingsContext, values: Record): Promise { + return provider.replaceSessionConfig(ctx.sessionId, values); } - private _resolve(parsed: IParsedSettingsUri): { provider: IAgentHostSessionsProvider; sessionId: string } { - const provider = this._lookupProvider(parsed.providerId); - if (!provider) { - throw createFileSystemProviderError( - `Unknown agent host provider: ${parsed.providerId}`, - FileSystemProviderErrorCode.FileNotFound, - ); - } - return { provider, sessionId: parsed.sessionId }; + protected _describeForTrace(ctx: ISessionSettingsContext): string { + return `session ${ctx.sessionId}`; } } /** - * Convert a session config property schema (protocol shape) into an - * {@link IJSONSchema} suitable for registration with the JSON language - * service. - */ -function convertPropertySchema(schema: SessionConfigPropertySchema): IJSONSchema { - const out: IJSONSchema = { - type: schema.type, - title: schema.title, - description: schema.description, - default: schema.default, - }; - if (schema.enum && schema.enum.length > 0) { - out.enum = [...schema.enum]; - if (schema.enumDescriptions && schema.enumDescriptions.length > 0) { - out.enumDescriptions = [...schema.enumDescriptions]; - } - } - if (schema.type === 'array' && schema.items) { - out.items = convertPropertySchema(schema.items); - } - if (schema.type === 'object' && schema.properties) { - const properties: Record = {}; - for (const [key, value] of Object.entries(schema.properties)) { - properties[key] = convertPropertySchema(value); - } - out.properties = properties; - if (schema.required && schema.required.length > 0) { - out.required = [...schema.required]; - } - } - return out; -} - -/** - * Build a JSON schema describing the editable session-mutable, non-readOnly - * properties of an agent-host session config. The filter mirrors the one in - * {@link serializeSessionSettings} so validation matches the file contents - * produced by this provider. - */ -export function buildSessionSettingsJsonSchema(config: ResolveSessionConfigResult): IJSONSchema { - const properties: Record = {}; - const required: string[] = []; - for (const [key, schema] of Object.entries(config.schema.properties)) { - if (!schema.sessionMutable || schema.readOnly) { - continue; - } - properties[key] = convertPropertySchema(schema); - if (config.schema.required?.includes(key)) { - required.push(key); - } - } - const result: IJSONSchema = { - type: 'object', - properties, - additionalProperties: true, - }; - if (required.length > 0) { - result.required = required; - } - return result; -} - -/** - * Keeps per-session JSON schemas registered on the - * {@link IJSONContributionRegistry} so editors of the synthetic + * Keeps per-session JSON schemas registered so editors of the synthetic * `agent-session-settings://…` files get completions, hover, and validation. - * - * Registration is lazy — {@link ensureRegistered} is called by - * {@link AgentSessionSettingsFileSystemProvider.readFile} the first time a - * session's settings document is read, so we avoid the JSON language - * service overhead for sessions that are never opened. Once registered, the - * schema is kept in sync via `onDidChangeSessionConfig` until the session - * or its provider is removed. - * - * A schema is rebuilt only when the session's underlying - * {@link SessionConfigSchema} changes by identity (protocol config schemas - * are treated as immutable snapshots); value-only changes are ignored to - * avoid churning the JSON language service. */ -export class AgentSessionSettingsSchemaRegistrar extends Disposable { +export class AgentSessionSettingsSchemaRegistrar extends AbstractAgentHostConfigSchemaRegistrar { - private readonly _schemaRegistry = Registry.as(JSONExtensions.JSONContribution); - - /** Per-provider subscriptions (session listeners, config listeners). */ - private readonly _providerSubscriptions = this._register(new DisposableMap()); + protected _propertyFilter(): AgentHostConfigPropertyFilter { + return sessionSettingsPropertyFilter; + } - /** Per-session registered-schema disposables, keyed by the settings URI string. */ - private readonly _sessionSchemas = this._register(new DisposableMap()); + protected _settingsUri(session: ISession): string { + return agentSessionSettingsUri(session).toString(); + } - /** - * Tracks the {@link SessionConfigSchema} identity last used to register - * a schema for a given settings URI, so we can skip re-registration when - * only values have changed. - */ - private readonly _lastSchemaIdentity = new Map(); + // Schema content is served via the `vscode://schemas/...` filesystem + // provider (see `SettingsFileSystemProvider`); the JSON language client + // only knows how to fetch schema content for that scheme. The + // settings-file URI is used as the fileMatch glob so the schema is + // applied to the actual editor document. + protected _schemaId(session: ISession): string { + return `vscode://schemas/agent-session-settings/${session.providerId}/${session.resource.scheme}/${session.resource.path}.jsonc`; + } - constructor( - @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, - ) { - super(); + protected _getConfig(provider: IAgentHostSessionsProvider, session: ISession): IAgentHostConfigLike | undefined { + return provider.getSessionConfig(session.sessionId); + } - for (const provider of this._sessionsProvidersService.getProviders()) { - this._onProviderAdded(provider); - } - this._register(this._sessionsProvidersService.onDidChangeProviders(e => { - for (const provider of e.added) { - this._onProviderAdded(provider); - } - for (const provider of e.removed) { - this._onProviderRemoved(provider); - } - })); + protected _targetsForProvider(provider: IAgentHostSessionsProvider): readonly ISession[] { + return provider.getSessions(); } - private _onProviderAdded(provider: ISessionsProvider): void { - if (!isAgentHostProvider(provider)) { - return; - } + protected _observeProvider( + provider: IAgentHostSessionsProvider, + onChanged: (session: ISession) => void, + onRemoved: (session: ISession) => void, + ): IDisposable { const store = new DisposableStore(); - - // Note: we do NOT seed schemas eagerly here — registration is lazy and - // only happens on the first `readFile` for a given session via - // {@link ensureRegistered}. Registering schemas is relatively expensive - // for the JSON language service, so we avoid paying that cost for - // sessions whose settings files are never opened. - store.add(provider.onDidChangeSessionConfig(sessionId => { - const schemaUri = this._schemaUriForSession(provider.id, sessionId); - // Only refresh if we already have a registration; otherwise the - // next `readFile` will pick up the latest schema on demand. - if (!schemaUri || !this._lastSchemaIdentity.has(schemaUri)) { - return; - } const session = provider.getSessions().find(s => s.sessionId === sessionId); if (session) { - this._refreshSchema(provider, session); + onChanged(session); } })); - store.add(provider.onDidChangeSessions(e => { for (const removed of e.removed) { - this._disposeSchema(removed); - } - })); - - // On provider disposal, drop all session schemas for this provider. - store.add(toDisposable(() => { - for (const session of provider.getSessions()) { - this._disposeSchema(session); + onRemoved(removed); } })); - - this._providerSubscriptions.set(provider.id, store); - } - - private _onProviderRemoved(provider: ISessionsProvider): void { - this._providerSubscriptions.deleteAndDispose(provider.id); - } - - /** - * Ensures a JSON schema is registered for the given session. Called - * lazily by the filesystem provider when a settings file is first read - * so we avoid the cost of registering schemas for sessions that are - * never opened. - * - * Once registered, the schema is kept in sync via - * `onDidChangeSessionConfig` until the session or its provider is - * removed. - */ - ensureRegistered(session: ISession): void { - const provider = this._sessionsProvidersService.getProvider(session.providerId); - if (!provider || !isAgentHostProvider(provider)) { - return; - } - this._refreshSchema(provider, session); - } - - private _schemaUriForSession(providerId: string, sessionId: string): string | undefined { - const provider = this._sessionsProvidersService.getProvider(providerId); - if (!provider || !isAgentHostProvider(provider)) { - return undefined; - } - const session = provider.getSessions().find(s => s.sessionId === sessionId); - return session ? agentSessionSettingsUri(session).toString() : undefined; - } - - private _refreshSchema(provider: IAgentHostSessionsProvider, session: ISession): void { - const config = provider.getSessionConfig(session.sessionId); - if (!config) { - return; - } - const settingsUri = agentSessionSettingsUri(session).toString(); - // Schema content is served via the `vscode://schemas/...` filesystem - // provider (see `SettingsFileSystemProvider`); the JSON language - // client only knows how to fetch schema content for that scheme. - // The settings-file URI is used as the fileMatch glob so the schema - // is applied to the actual editor document. - const schemaId = `vscode://schemas/agent-session-settings/${session.providerId}/${session.resource.scheme}/${session.resource.path}.jsonc`; - const identity = config.schema; - if (this._lastSchemaIdentity.get(settingsUri) === identity) { - return; - } - - const schema = buildSessionSettingsJsonSchema(config); - - // Dispose any prior registration first, otherwise the old cleanup - // disposable would delete the freshly registered schema. Clear the - // identity cache as a side effect so we always proceed to register. - this._sessionSchemas.deleteAndDispose(settingsUri); - - const store = new DisposableStore(); - this._schemaRegistry.registerSchema(schemaId, schema, store); - store.add(this._schemaRegistry.registerSchemaAssociation(schemaId, settingsUri)); - store.add(toDisposable(() => this._lastSchemaIdentity.delete(settingsUri))); - - this._sessionSchemas.set(settingsUri, store); - this._lastSchemaIdentity.set(settingsUri, identity); - } - - private _disposeSchema(session: ISession): void { - const schemaUri = agentSessionSettingsUri(session).toString(); - this._sessionSchemas.deleteAndDispose(schemaUri); + return store; } } - diff --git a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts index 2e6e07c72cdd1..3ef9840244325 100644 --- a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -17,7 +17,7 @@ import { localize } from '../../../../nls.js'; import { AgentSession, IAgentConnection, IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js'; import { ResolveSessionConfigResult } from '../../../../platform/agentHost/common/state/protocol/commands.js'; import { NotificationType } from '../../../../platform/agentHost/common/state/protocol/notifications.js'; -import { FileEdit, ModelSelection, RootState, SessionState, SessionSummary, SessionStatus as ProtocolSessionStatus } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import { FileEdit, ModelSelection, RootConfigState, RootState, SessionState, SessionSummary, SessionStatus as ProtocolSessionStatus } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; import { StateComponents } from '../../../../platform/agentHost/common/state/sessionState.js'; import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; @@ -44,7 +44,7 @@ import { ISendRequestOptions, ISessionChangeEvent } from '../../../services/sess */ export interface IAgentHostAdapterOptions { readonly icon: ThemeIcon; - readonly description: IMarkdownString; + readonly description: IMarkdownString | undefined; /** Loading observable wired to the provider's authentication-pending state. */ readonly loading: IObservable; /** Builds the session workspace from session metadata; provider-specific (icon, providerLabel, requiresWorkspaceTrust). */ @@ -249,6 +249,12 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement protected readonly _onDidChangeSessionConfig = this._register(new Emitter()); readonly onDidChangeSessionConfig = this._onDidChangeSessionConfig.event; + protected readonly _onDidChangeRootConfig = this._register(new Emitter()); + readonly onDidChangeRootConfig = this._onDidChangeRootConfig.event; + + /** Last-known root config state (schema + values), seeded from `RootState.config`. */ + protected _rootConfig: RootConfigState | undefined; + /** Cache of adapted sessions, keyed by raw session ID. */ protected readonly _sessionCache = new Map(); @@ -359,6 +365,28 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._onDidChangeSessionTypes.fire(); } + /** + * Reconcile {@link _rootConfig} against {@link RootState.config}, firing + * {@link onDidChangeRootConfig} only when schema or values actually change. + */ + protected _syncRootConfigFromRootState(rootState: RootState): void { + const next = rootState.config; + const prev = this._rootConfig; + if (prev === next) { + return; + } + if (!next) { + this._rootConfig = undefined; + this._onDidChangeRootConfig.fire(); + return; + } + if (prev && prev.schema === next.schema && equals(prev.values, next.values)) { + return; + } + this._rootConfig = next; + this._onDidChangeRootConfig.fire(); + } + abstract resolveWorkspace(repositoryUri: URI): ISessionWorkspace | undefined; /** Optional event fired when the underlying connection is lost; used to short-circuit `_waitForNewSession`. */ @@ -575,7 +603,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement const nextValues: Record = {}; for (const [key, schema] of Object.entries(runningConfig.schema.properties)) { const editable = schema.sessionMutable === true && schema.readOnly !== true; - if (editable && Object.hasOwn(values, key)) { + if (editable) { nextValues[key] = values[key]; } else if (Object.hasOwn(runningConfig.values, key)) { nextValues[key] = runningConfig.values[key]; @@ -633,6 +661,67 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._clearNewSessionConfig(sessionId); } + // -- Root (agent host) Config -------------------------------------------- + + getRootConfig(): RootConfigState | undefined { + return this._rootConfig; + } + + async setRootConfigValue(property: string, value: unknown): Promise { + const current = this._rootConfig; + const connection = this.connection; + if (!current || !connection) { + return; + } + if (!current.schema.properties[property]) { + return; + } + + // Optimistically update local cache. + this._rootConfig = { + ...current, + values: { ...current.values, [property]: value }, + }; + this._onDidChangeRootConfig.fire(); + + const action = { + type: ActionType.RootConfigChanged as const, + config: { [property]: value }, + }; + connection.dispatch(action); + } + + async replaceRootConfig(values: Record): Promise { + const current = this._rootConfig; + const connection = this.connection; + if (!current || !connection) { + return; + } + + // Filter to known properties so we don't dispatch values for keys the + // host didn't publish a schema for. + const nextValues: Record = {}; + for (const [key, value] of Object.entries(values)) { + if (current.schema.properties[key]) { + nextValues[key] = value; + } + } + + if (equals(nextValues, current.values)) { + return; + } + + this._rootConfig = { ...current, values: nextValues }; + this._onDidChangeRootConfig.fire(); + + const action = { + type: ActionType.RootConfigChanged as const, + config: nextValues, + replace: true, + }; + connection.dispatch(action); + } + // -- Model selection ------------------------------------------------------ setModel(sessionId: string, modelId: string): void { diff --git a/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts index eb9889aab987a..dfe47123109b0 100644 --- a/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts @@ -71,9 +71,11 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide const rootStateValue = this._agentHostService.rootState.value; if (rootStateValue && !(rootStateValue instanceof Error)) { this._syncSessionTypesFromRootState(rootStateValue); + this._syncRootConfigFromRootState(rootStateValue); } this._register(this._agentHostService.rootState.onDidChange(rootState => { this._syncSessionTypesFromRootState(rootState); + this._syncRootConfigFromRootState(rootState); })); // Eagerly populate the session cache once authentication has settled. diff --git a/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 279685b9008d0..2aadbca6d3cae 100644 --- a/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -14,7 +14,7 @@ import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelSc import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { AgentSession, IAgentHostService, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js'; import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; -import type { SessionAction, TerminalAction } from '../../../../../platform/agentHost/common/state/protocol/action-origin.generated.js'; +import type { RootAction, SessionAction, TerminalAction } from '../../../../../platform/agentHost/common/state/protocol/action-origin.generated.js'; import type { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { NotificationType } from '../../../../../platform/agentHost/common/state/protocol/notifications.js'; import { SessionLifecycle, type AgentInfo, type ModelSelection, type RootState, type SessionConfigState, type SessionState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -47,7 +47,7 @@ class MockAgentHostService extends mock() { override readonly clientId = 'test-local-client'; private readonly _sessions = new Map(); public disposedSessions: URI[] = []; - public dispatchedActions: { action: SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = []; + public dispatchedActions: { action: RootAction | SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = []; public failResolveSessionConfig = false; public resolveSessionConfigResult: ResolveSessionConfigResult = { schema: { type: 'object', properties: {} }, values: { isolation: 'worktree' } }; @@ -93,11 +93,11 @@ class MockAgentHostService extends mock() { return this.resolveSessionConfigResult; } - dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void { + dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void { this.dispatchedActions.push({ action, clientId, clientSeq }); } - override dispatch(action: SessionAction | TerminalAction): void { + override dispatch(action: RootAction | SessionAction | TerminalAction): void { this.dispatchedActions.push({ action, clientId: this.clientId, clientSeq: this._nextSeq++ }); } diff --git a/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.contribution.ts b/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.contribution.ts new file mode 100644 index 0000000000000..6ca1ee8847a6c --- /dev/null +++ b/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.contribution.ts @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { SessionBrowserViewController } from './sessionBrowserView.js'; + +registerWorkbenchContribution2(SessionBrowserViewController.ID, SessionBrowserViewController, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.ts b/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.ts new file mode 100644 index 0000000000000..4ccd44aca7474 --- /dev/null +++ b/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; +import { IBrowserViewWorkbenchService } from '../../../../workbench/contrib/browserView/common/browserView.js'; +import { BrowserEditorInput } from '../../../../workbench/contrib/browserView/common/browserEditorInput.js'; +import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISession } from '../../../services/sessions/common/session.js'; +import { runOnChange } from '../../../../base/common/observable.js'; + +export class SessionBrowserViewController extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessionBrowserViewController'; + + /** + * Tracks browser view inputs with their owning session. The + * DisposableMap cleans up lifecycle listeners on deletion/disposal. + */ + private readonly _trackedInputs = this._register(new DisposableMap void }>()); + + constructor( + @ISessionsManagementService private readonly _sessionManagementService: ISessionsManagementService, + @IBrowserViewWorkbenchService private readonly _browserViewService: IBrowserViewWorkbenchService, + @IEditorService private readonly _editorService: IEditorService, + @IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService, + ) { + super(); + + // Catch editors opened via normal user/tool actions. + this._register(this._editorService.onWillOpenEditor(e => { + if (e.editor instanceof BrowserEditorInput) { + this._attachLifecycle(e.editor); + } + })); + + // Catch editors restored from a working set swap — onWillOpenEditor + // does not fire for deserialized editors, but onDidAddGroup fires + // after the group (with its editors) has been created. + this._register(this._editorGroupsService.onDidAddGroup(group => { + for (const editor of group.editors) { + if (editor instanceof BrowserEditorInput) { + this._attachLifecycle(editor); + } + } + })); + + // Force-destroy browser views when sessions are removed. + this._register(this._sessionManagementService.onDidChangeSessions(e => { + if (e.removed.length === 0 || this._trackedInputs.size === 0) { + return; + } + + const removedSessionIds = new Set(e.removed.map(s => s.resource.toString())); + const known = this._browserViewService.getKnownBrowserViews(); + for (const [id, { session }] of this._trackedInputs) { + if (removedSessionIds.has(session.resource.toString())) { + const existingInput = known.get(id); + if (existingInput instanceof BrowserEditorInput) { + existingInput.dispose(true); + } + } + } + })); + } + + private _attachLifecycle(input: BrowserEditorInput): void { + if (this._trackedInputs.has(input.id)) { + return; + } + + const session = this._sessionManagementService.activeSession.read(undefined); + if (!session) { + return; // no session, no lifecycle management needed + } + + const store = new DisposableStore(); + this._trackedInputs.set(input.id, { session, dispose: () => store.dispose() }); + + // When the owning session is archived, force-dispose the browser view. + store.add(runOnChange(session.isArchived, (isArchived) => { + if (isArchived) { + input.dispose(true); + } + })); + + store.add(input.onBeforeDispose(e => { + const activeSession = this._sessionManagementService.activeSession.read(undefined); + + // If the input is being disposed, but we are not currently in the owning session, + // assume a session swap is happening and do not actually dispose the browser yet. + if (session.sessionId !== activeSession?.sessionId) { + e.veto(); + } + })); + + store.add(input.onWillDispose(() => { + store.dispose(); + this._trackedInputs.deleteAndDispose(input.id); + })); + } +} diff --git a/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts b/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts index a57be3a52ecd9..63b7f89619467 100644 --- a/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts +++ b/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts @@ -56,7 +56,7 @@ export class ChangesTitleBarContribution extends Disposable implements IWorkbenc }, }, group: 'navigation', - order: 11, // After Run Script (8), Open in VS Code (9), and Open Terminal (10) + order: 11, // After Open in VS Code (7), Run Script (8), and Open Terminal (10) when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), })); } diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index a4fe043454555..eeaeac4022403 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -17,6 +17,7 @@ import { IsNewChatInSessionContext, IsNewChatSessionContext } from '../../../com import { BranchChatSessionAction } from './branchChatSessionAction.js'; import { RunScriptContribution } from './runScriptAction.js'; import './nullInlineChatSessionService.js'; +import './openInVSCodeWidget.js'; import './nullChatTipService.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; diff --git a/src/vs/sessions/contrib/chat/browser/media/openInVSCode.css b/src/vs/sessions/contrib/chat/browser/media/openInVSCode.css new file mode 100644 index 0000000000000..0ca469df6b076 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/openInVSCode.css @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* "Open in VS Code" titlebar widget — icon-only at rest, expands on hover/focus. */ +.monaco-workbench .open-in-vscode-titlebar-widget { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 4px; + margin: 0; + border-radius: 5px; + cursor: pointer; + color: var(--vscode-titleBar-activeForeground); + -webkit-app-region: no-drag; + white-space: nowrap; + position: relative; + touch-action: manipulation; +} + +.monaco-workbench .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-icon { + width: 16px; + height: 16px; + flex: 0 0 auto; + /* Dev fallback: the VS Code shield logo bundled in the sessions media folder. + * In production builds the distro mixin overwrites + * vs/workbench/browser/media/code-icon.svg with the quality-branded icon; + * the per-quality rules below then take precedence. */ + background-image: url('./vscode-icon.svg'); + background-repeat: no-repeat; + background-position: center center; + background-size: contain; +} + +/* In production builds vscode-distro overlays vs/workbench/browser/media/code-icon.svg + * with the quality-specific branded VS Code icon. Use it whenever the product quality is + * known (the data-product-quality attribute is only set in non-dev builds). */ +.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="stable"] > .open-in-vscode-titlebar-widget-icon, +.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="insider"] > .open-in-vscode-titlebar-widget-icon, +.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="exploration"] > .open-in-vscode-titlebar-widget-icon { + background-image: url('../../../../../workbench/browser/media/code-icon.svg'); +} + +.monaco-workbench .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-label { + display: inline-block; + max-width: 0; + opacity: 0; + margin-left: 0; + color: var(--vscode-foreground); + font: inherit; + overflow: hidden; + white-space: nowrap; +} + +.monaco-enable-motion .monaco-workbench .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-label, +.monaco-workbench.monaco-enable-motion .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-label { + transition: max-width 150ms ease, opacity 150ms ease, margin-left 150ms ease; +} + +.monaco-reduce-motion .monaco-workbench .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-label, +.monaco-workbench.monaco-reduce-motion .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-label { + transition-duration: 0ms !important; +} + +.monaco-workbench .open-in-vscode-titlebar-widget:hover, +.monaco-workbench .open-in-vscode-titlebar-widget:focus-visible { + background-color: var(--vscode-toolbar-hoverBackground); + outline: none; +} + +/* Quality-tinted hover/focus background — blue (stable), green (insider), orange (exploration). */ +.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="stable"]:hover, +.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="stable"]:focus-visible { + background-color: rgba(0, 122, 204, 0.18); +} + +.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="insider"]:hover, +.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="insider"]:focus-visible { + background-color: rgba(36, 187, 26, 0.20); +} + +.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="exploration"]:hover, +.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="exploration"]:focus-visible { + background-color: rgba(255, 140, 0, 0.22); +} + +.monaco-workbench .open-in-vscode-titlebar-widget:hover > .open-in-vscode-titlebar-widget-label, +.monaco-workbench .open-in-vscode-titlebar-widget:focus-visible > .open-in-vscode-titlebar-widget-label { + max-width: 200px; + opacity: 1; + margin-left: 6px; +} + +.monaco-workbench .open-in-vscode-titlebar-widget:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} diff --git a/src/vs/sessions/contrib/chat/browser/media/vscode-icon.svg b/src/vs/sessions/contrib/chat/browser/media/vscode-icon.svg new file mode 100644 index 0000000000000..39ff8ec6d02c3 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/vscode-icon.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 1d2c7cea31d39..3e2bcb734c0e1 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -5,7 +5,7 @@ import './media/chatWidget.css'; import * as dom from '../../../../base/browser/dom.js'; -import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { derived } from '../../../../base/common/observable.js'; import { isWeb } from '../../../../base/common/platform.js'; import { URI } from '../../../../base/common/uri.js'; @@ -36,6 +36,9 @@ class NewChatWidget extends Disposable { private readonly _workspacePicker: WorkspacePicker; private readonly _newChatInput: NewChatInputWidget; + /** Tracks an in-flight wait for a provider's session types to become available. */ + private readonly _pendingSessionTypeWait = new MutableDisposable(); + constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, @ILogService private readonly logService: ILogService, @@ -45,6 +48,7 @@ class NewChatWidget extends Disposable { ) { super(); this._workspacePicker = this._register(this.instantiationService.createInstance(isWeb ? ScopedWorkspacePicker : WorkspacePicker)); + this._register(this._pendingSessionTypeWait); const canSendRequest = derived(reader => { const session = this.sessionsManagementService.activeSession.read(reader); @@ -95,21 +99,14 @@ class NewChatWidget extends Disposable { this._newChatInput.render(chatWidgetContent, parent); - // Create initial session — wait for providers if none registered yet. + // Create initial session for any workspace already selected at construct time. + // If the selection arrives later (provider registers asynchronously), the + // picker fires onDidSelectWorkspace and our listener handles it. // Skip if an active session already exists (restored by openNewSessionView // from a pending new session when navigating back from another session). const restoredProject = this._workspacePicker.selectedProject; if (!this._syncWorkspacePickerFromActiveSession() && restoredProject) { - if (this.sessionsProvidersService.getProviders().length > 0) { - this._createNewSession(restoredProject, this._newChatInput.sessionTypePicker.selectedType); - } else { - // Providers not yet registered (startup race) — wait for first registration - const sub = this.sessionsProvidersService.onDidChangeProviders(() => { - sub.dispose(); - this._createNewSession(restoredProject, this._newChatInput.sessionTypePicker.selectedType); - }); - this._register(sub); - } + this._createNewSession(restoredProject, this._newChatInput.sessionTypePicker.selectedType); } chatWidgetContainer.classList.add('revealed'); @@ -143,7 +140,42 @@ class NewChatWidget extends Disposable { } private _createNewSession(selection: IWorkspaceSelection, sessionTypeId: string | undefined): void { - this.sessionsManagementService.createNewSession(selection.providerId, selection.workspace.repositories[0].uri, sessionTypeId); + const provider = this.sessionsProvidersService.getProviders().find(p => p.id === selection.providerId); + const repoUri = selection.workspace.repositories[0].uri; + + // Drop the carried-over sessionTypeId if it doesn't apply to this provider — + // happens when the picker upgrades to a different provider after restore and + // the previous active session's type (e.g. EH CLI's "agents") doesn't exist + // on the new provider (e.g. agent host). + if (sessionTypeId && provider && !provider.getSessionTypes(repoUri).some(t => t.id === sessionTypeId)) { + sessionTypeId = undefined; + } + + // Session types may not be available yet (e.g., agent host still connecting). + // If so, wait for them before creating the session — otherwise createNewSession + // throws and the new chat view is left without an active session, which hides + // agent-host-specific UI (model picker etc.) until the user re-picks the workspace. + // If the connection fails, the picker fires onDidSelectWorkspace(undefined) which + // clears the pending wait via _onWorkspaceSelected. + if (provider && !sessionTypeId && provider.getSessionTypes(repoUri).length === 0 && provider.onDidChangeSessionTypes) { + const pendingStore = new DisposableStore(); + this._pendingSessionTypeWait.value = pendingStore; + + pendingStore.add(provider.onDidChangeSessionTypes(() => { + if (provider.getSessionTypes(repoUri).length > 0) { + this._pendingSessionTypeWait.clear(); + this._createNewSession(selection, sessionTypeId); + } + })); + + return; + } + + try { + this.sessionsManagementService.createNewSession(selection.providerId, repoUri, sessionTypeId); + } catch (e) { + this.logService.error('Failed to create new session:', e); + } } /** @@ -210,6 +242,9 @@ class NewChatWidget extends Disposable { * Requests folder trust if needed and creates a new session. */ private async _onWorkspaceSelected(selection: IWorkspaceSelection | undefined, sessionTypeId: string | undefined): Promise { + // Cancel any in-flight wait for a previous selection. + this._pendingSessionTypeWait.clear(); + if (!selection) { this.sessionsManagementService.unsetNewSession(); return; diff --git a/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts b/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts index 0dd1cb31396db..652d45c9f348d 100644 --- a/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts @@ -41,7 +41,7 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { menu: [{ id: Menus.TitleBarSessionMenu, group: 'navigation', - order: 9, + order: 7, when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsPhoneLayoutContext.negate()), }] }); diff --git a/src/vs/sessions/contrib/chat/browser/openInVSCodeWidget.ts b/src/vs/sessions/contrib/chat/browser/openInVSCodeWidget.ts new file mode 100644 index 0000000000000..8f3defa9c9c4a --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/openInVSCodeWidget.ts @@ -0,0 +1,87 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/openInVSCode.css'; +import { $, append, EventHelper, EventLike } from '../../../../base/browser/dom.js'; +import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IAction } from '../../../../base/common/actions.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { Menus } from '../../../browser/menus.js'; + +const OpenInVSCodeActionId = 'chat.openSessionWorktreeInVSCode'; + +/** + * Renders the "Open in VS Code" titlebar entry as an icon-only button that + * expands to reveal a label on hover / keyboard focus. + */ +class OpenInVSCodeTitleBarWidget extends BaseActionViewItem { + + constructor( + action: IAction, + options: IBaseActionViewItemOptions | undefined, + @IProductService private readonly productService: IProductService, + @IHoverService private readonly hoverService: IHoverService, + ) { + super(undefined, action, options); + } + + override render(container: HTMLElement): void { + super.render(container); + + container.classList.add('open-in-vscode-titlebar-widget'); + container.setAttribute('role', 'button'); + + // Set quality attribute for quality-tinted hover styling and distro icon selection. + // Only set when quality is known so that the CSS fallback icon is used in dev builds. + const quality = this.productService.quality; + if (quality) { + container.setAttribute('data-product-quality', quality); + } + + const label = this.action.label || localize('openInVSCodeLabel', "Open in VS Code"); + container.setAttribute('aria-label', label); + this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), container, label)); + + const icon = append(container, $('span.open-in-vscode-titlebar-widget-icon')); + icon.setAttribute('aria-hidden', 'true'); + + const labelEl = append(container, $('span.open-in-vscode-titlebar-widget-label')); + labelEl.textContent = label; + } + + override onClick(event: EventLike): void { + EventHelper.stop(event, true); + this.action.run(); + } +} + +/** + * Workbench contribution that registers the custom action view item for + * the "Open in VS Code" action in the sessions titlebar toolbar, replacing + * the default icon-only codicon with a rich expandable widget. + */ +class OpenInVSCodeWidgetContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.openInVSCode.widget'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + this._register(actionViewItemService.register(Menus.TitleBarSessionMenu, OpenInVSCodeActionId, (action, options) => { + return instantiationService.createInstance(OpenInVSCodeTitleBarWidget, action, options); + }, undefined)); + } +} + +registerWorkbenchContribution2(OpenInVSCodeWidgetContribution.ID, OpenInVSCodeWidgetContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/sessions/contrib/chat/browser/scopedWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/scopedWorkspacePicker.ts index a1932e8538fce..3899e133ab0c0 100644 --- a/src/vs/sessions/contrib/chat/browser/scopedWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/scopedWorkspacePicker.ts @@ -19,7 +19,6 @@ import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uri import { IOutputService } from '../../../../workbench/services/output/common/output.js'; import { IPreferencesService } from '../../../../workbench/services/preferences/common/preferences.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; -import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { IAgentHostFilterService } from '../../remoteAgentHost/common/agentHostFilter.js'; import { IWorkspacePickerItem, IWorkspaceSelection, WorkspacePicker } from './sessionWorkspacePicker.js'; import { IWorkspacesService } from '../../../../platform/workspaces/common/workspaces.js'; @@ -41,7 +40,6 @@ export class ScopedWorkspacePicker extends WorkspacePicker { @IStorageService storageService: IStorageService, @IUriIdentityService uriIdentityService: IUriIdentityService, @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, - @ISessionsManagementService sessionsManagementService: ISessionsManagementService, @IRemoteAgentHostService remoteAgentHostService: IRemoteAgentHostService, @IQuickInputService quickInputService: IQuickInputService, @IClipboardService clipboardService: IClipboardService, @@ -59,7 +57,6 @@ export class ScopedWorkspacePicker extends WorkspacePicker { storageService, uriIdentityService, sessionsProvidersService, - sessionsManagementService, remoteAgentHostService, quickInputService, clipboardService, diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index 92a348ef5d37b..c649172a7ba14 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -8,9 +8,11 @@ import * as touch from '../../../../base/browser/touch.js'; import { IAction, SubmenuAction, toAction } from '../../../../base/common/actions.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../base/common/event.js'; +import { disposableTimeout } from '../../../../base/common/async.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; import { basename } from '../../../../base/common/resources.js'; +import { autorun } from '../../../../base/common/observable.js'; import { localize } from '../../../../nls.js'; import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; @@ -26,12 +28,10 @@ import { IOutputService } from '../../../../workbench/services/output/common/out import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; -import { autorun } from '../../../../base/common/observable.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { ISessionWorkspace, ISessionWorkspaceBrowseAction } from '../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; -import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; import { COPILOT_PROVIDER_ID } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js'; import { IWorkspacesService, isRecentFolder } from '../../../../platform/workspaces/common/workspaces.js'; @@ -42,6 +42,14 @@ const STORAGE_KEY_RECENT_WORKSPACES = 'sessions.recentlyPickedWorkspaces'; const FILTER_THRESHOLD = 10; const MAX_RECENT_WORKSPACES = 10; +/** + * Grace period for a restored remote workspace's provider to reach Connected + * before we fall back to no selection. SSH tunnels typically connect within + * a couple seconds; if it hasn't connected by then, we'd rather show no + * selection than leave the user staring at an unreachable workspace. + */ +const RESTORE_CONNECT_GRACE_MS = 5000; + /** * A workspace selection from the picker, pairing the workspace with its owning provider. */ @@ -86,9 +94,24 @@ export class WorkspacePicker extends Disposable { private _selectedWorkspace: IWorkspaceSelection | undefined; + /** + * Set to `true` once the user has explicitly picked or cleared a workspace. + * Until then, late-arriving provider registrations are allowed to upgrade + * the current (auto-restored) selection to the user's stored "checked" + * entry. After the user has acted, providers coming and going never move + * the selection out from under them. + */ + private _userHasPicked = false; + + /** + * Watches the connection status of a restored remote workspace. Cleared when + * the user explicitly picks, when the connection succeeds, or when it fails + * and we fall back. + */ + private readonly _connectionStatusWatch = this._register(new MutableDisposable()); + private _triggerElement: HTMLElement | undefined; private readonly _renderDisposables = this._register(new DisposableStore()); - private readonly _connectionStatusListener = this._register(new MutableDisposable()); /** Cached VS Code recent folder URIs, resolved lazily. */ private _vsCodeRecentFolderUris: URI[] = []; @@ -102,7 +125,6 @@ export class WorkspacePicker extends Disposable { @IStorageService private readonly storageService: IStorageService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @ISessionsProvidersService protected readonly sessionsProvidersService: ISessionsProvidersService, - @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @IRemoteAgentHostService private readonly remoteAgentHostService: IRemoteAgentHostService, @IQuickInputService private readonly quickInputService: IQuickInputService, @IClipboardService private readonly clipboardService: IClipboardService, @@ -121,32 +143,37 @@ export class WorkspacePicker extends Disposable { // Restore selected workspace from storage this._selectedWorkspace = this._restoreSelectedWorkspace(); + if (this._selectedWorkspace) { + this._watchForConnectionFailure(this._selectedWorkspace); + } // React to provider registrations/removals: re-validate the current - // selection and attempt to restore a stored workspace when none is active. + // selection, and if the user hasn't explicitly picked yet, re-restore + // from storage so we upgrade from any fallback to the user's actual + // stored selection once its provider arrives. this._register(this.sessionsProvidersService.onDidChangeProviders(() => { if (this._selectedWorkspace) { - // Validate that the selected workspace's provider is still registered const providers = this.sessionsProvidersService.getProviders(); if (!providers.some(p => p.id === this._selectedWorkspace!.providerId)) { this._selectedWorkspace = undefined; + this._connectionStatusWatch.clear(); this._updateTriggerLabel(); + this._onDidChangeSelection.fire(); + this._onDidSelectWorkspace.fire(undefined); } } - if (!this._selectedWorkspace) { + if (!this._userHasPicked) { const restored = this._restoreSelectedWorkspace(); - if (restored) { + if (restored && !this._isSelectedWorkspace(restored)) { this._selectedWorkspace = restored; this._updateTriggerLabel(); this._onDidChangeSelection.fire(); this._onDidSelectWorkspace.fire(restored); + this._watchForConnectionFailure(restored); } } - this._watchConnectionStatus(); })); - this._watchConnectionStatus(); - // Load VS Code recent folders eagerly and refresh on changes this._loadVSCodeRecentFolders(); this._register(this.workspacesService.onDidChangeRecentlyOpened(() => this._loadVSCodeRecentFolders())); @@ -256,6 +283,8 @@ export class WorkspacePicker extends Disposable { */ clearSelection(): void { this.actionWidgetService.hide(); + this._userHasPicked = true; + this._connectionStatusWatch.clear(); this._selectedWorkspace = undefined; // Clear checked state from all recents const recents = this._getStoredRecentWorkspaces(); @@ -275,6 +304,8 @@ export class WorkspacePicker extends Disposable { } private _selectProject(selection: IWorkspaceSelection, fireEvent = true): void { + this._userHasPicked = true; + this._connectionStatusWatch.clear(); this._selectedWorkspace = selection; this._persistSelectedWorkspace(selection); this._updateTriggerLabel(); @@ -304,18 +335,6 @@ export class WorkspacePicker extends Disposable { } } - private _getActiveProviders(): import('../../../services/sessions/common/sessionsProvider.js').ISessionsProvider[] { - const activeProviderId = this.sessionsManagementService.activeProviderId.get(); - const allProviders = this.sessionsProvidersService.getProviders(); - if (activeProviderId) { - const active = allProviders.find(p => p.id === activeProviderId); - if (active) { - return [active]; - } - } - return allProviders; - } - /** * Collects browse actions from all registered providers. */ @@ -630,44 +649,6 @@ export class WorkspacePicker extends Disposable { return provider.connectionStatus.get() !== RemoteAgentHostConnectionStatus.Connected; } - /** - * Watch connection status observables from all remote providers. - * When a remote disconnects, clear the selection if it belongs to that - * provider. When a remote reconnects, try to restore a stored workspace. - */ - private _watchConnectionStatus(): void { - const remoteProviders = this.sessionsProvidersService.getProviders().filter(isAgentHostProvider).filter(p => p.connectionStatus !== undefined); - if (remoteProviders.length === 0) { - this._connectionStatusListener.clear(); - return; - } - - this._connectionStatusListener.value = autorun(reader => { - for (const provider of remoteProviders) { - provider.connectionStatus!.read(reader); - } - - // If the current selection belongs to an unavailable provider, clear it - if (this._selectedWorkspace && this._isProviderUnavailable(this._selectedWorkspace.providerId)) { - this._selectedWorkspace = undefined; - this._updateTriggerLabel(); - this._onDidChangeSelection.fire(); - } - - // If no selection, try to restore the previously checked workspace - // (only the checked entry, not any fallback, to avoid unexpected switches) - if (!this._selectedWorkspace) { - const restored = this._restoreCheckedWorkspace(); - if (restored) { - this._selectedWorkspace = restored; - this._updateTriggerLabel(); - this._onDidChangeSelection.fire(); - this._onDidSelectWorkspace.fire(restored); - } - } - }); - } - protected _isSelectedWorkspace(selection: IWorkspaceSelection): boolean { if (!this._selectedWorkspace) { return false; @@ -695,9 +676,12 @@ export class WorkspacePicker extends Disposable { return checked; } - // Fall back to the first resolvable recent workspace from a connected provider + // Fall back to the first resolvable recent workspace from a connected provider. + // Fallbacks (vs. the user's explicit checked pick) require the provider + // to be ready: we don't want to silently land on, e.g., a disconnected + // remote workspace that the user never picked. try { - const providers = this._getActiveProviders(); + const providers = this.sessionsProvidersService.getProviders(); const providerIds = new Set(providers.map(p => p.id)); const storedRecents = this._getStoredRecentWorkspaces(); @@ -722,12 +706,14 @@ export class WorkspacePicker extends Disposable { /** * Restore only the checked (previously selected) workspace if its provider - * is currently available. Does not fall back to other workspaces. - * Used by the connection status watcher to avoid unexpected workspace switches. + * is registered. The provider's connection status is intentionally NOT + * checked — we honor the user's explicit pick even if the remote is still + * connecting or currently disconnected. The trigger label reflects the + * connection state separately (spinner / grayed). */ private _restoreCheckedWorkspace(): IWorkspaceSelection | undefined { try { - const providers = this._getActiveProviders(); + const providers = this.sessionsProvidersService.getProviders(); const providerIds = new Set(providers.map(p => p.id)); const storedRecents = this._getStoredRecentWorkspaces(); @@ -735,9 +721,6 @@ export class WorkspacePicker extends Disposable { if (!stored.checked || !providerIds.has(stored.providerId)) { continue; } - if (this._isProviderUnavailable(stored.providerId)) { - continue; - } const uri = URI.revive(stored.uri); const workspace = this.sessionsProvidersService.getProvider(stored.providerId)?.resolveWorkspace(uri); if (workspace) { @@ -750,6 +733,65 @@ export class WorkspacePicker extends Disposable { } } + /** + * When restoring a workspace whose provider isn't currently Connected, + * watch the connection status. Fires `onDidSelectWorkspace(undefined)` + * (which the view pane converts to `unsetNewSession()`) if: + * - the status transitions to Disconnected after we start watching, or + * - the status is still not Connected after a short grace period. + * + * The grace period covers a race: provider state can transition synchronously + * inside provider registration before our autorun's first read, so we may + * never observe an explicit Disconnected transition. The timer ensures we + * eventually fall back instead of leaving the picker showing an unreachable + * remote with no session. + * + * Has no effect once the user makes an explicit pick (`_userHasPicked`). + */ + private _watchForConnectionFailure(selection: IWorkspaceSelection): void { + const provider = this.sessionsProvidersService.getProvider(selection.providerId); + if (!provider || !isAgentHostProvider(provider) || !provider.connectionStatus) { + return; + } + const connStatus = provider.connectionStatus; + if (connStatus.get() === RemoteAgentHostConnectionStatus.Connected) { + return; + } + + const store = new DisposableStore(); + this._connectionStatusWatch.value = store; + + const fallback = () => { + this._connectionStatusWatch.clear(); + if (!this._userHasPicked && this._isSelectedWorkspace(selection)) { + this._selectedWorkspace = undefined; + this._updateTriggerLabel(); + this._onDidChangeSelection.fire(); + this._onDidSelectWorkspace.fire(undefined); + } + }; + + let isFirstRun = true; + store.add(autorun(reader => { + const status = connStatus.read(reader); + if (status === RemoteAgentHostConnectionStatus.Connected) { + this._connectionStatusWatch.clear(); + } else if (status === RemoteAgentHostConnectionStatus.Disconnected && !isFirstRun) { + fallback(); + } + isFirstRun = false; + })); + + // Safety net: if the connection hasn't succeeded by the grace period, + // fall back. Catches the case where the provider's status flips before + // our autorun subscribes (so we never observe a transition). + disposableTimeout(() => { + if (connStatus.get() !== RemoteAgentHostConnectionStatus.Connected) { + fallback(); + } + }, RESTORE_CONNECT_GRACE_MS, store); + } + /** * Migrate legacy `sessions.recentlyPickedProjects` storage to the new * `sessions.recentlyPickedWorkspaces` key, adding `providerId` (defaulting diff --git a/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts index b78163b6860ed..975d1a0112319 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts @@ -13,6 +13,8 @@ import { AGENT_HOST_SCHEME, fromAgentHostUri } from '../../../../platform/agentH import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { INativeHostService } from '../../../../platform/native/common/native.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; import { IsPhoneLayoutContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; @@ -23,13 +25,17 @@ import { ISessionsManagementService } from '../../../services/sessions/common/se import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { resolveRemoteAuthority } from '../browser/openInVSCodeUtils.js'; import { DebugAgentHostInDevToolsAction } from '../../../../workbench/contrib/chat/electron-browser/actions/debugAgentHostAction.js'; +import { isLinux } from '../../../../base/common/platform.js'; +import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; /** * Desktop version of the "Open in VS Code" action. * - * Launches the host VS Code app via {@link INativeHostService.launchSiblingApp} - * (child_process.spawn) with direct CLI arguments, bypassing protocol handlers - * and their OS security prompts. + * In built builds with a sibling app configured, launches the host VS Code app + * via {@link INativeHostService.launchSiblingApp} (child_process.spawn) with + * direct CLI arguments, bypassing protocol handlers and their OS security + * prompts. In dev builds (no sibling app), falls back to the protocol handler + * approach via {@link IOpenerService}. */ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { static readonly ID = 'chat.openSessionWorktreeInVSCode'; @@ -43,7 +49,7 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { menu: [{ id: Menus.TitleBarSessionMenu, group: 'navigation', - order: 9, + order: 7, when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsPhoneLayoutContext.negate()), }] }); @@ -53,7 +59,8 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { const telemetryService = accessor.get(ITelemetryService); logSessionsInteraction(telemetryService, 'openInVSCode'); - const nativeHostService = accessor.get(INativeHostService); + const productService = accessor.get(IProductService); + const environmentService = accessor.get(IEnvironmentService); const sessionsManagementService = accessor.get(ISessionsManagementService); const sessionsProvidersService = accessor.get(ISessionsProvidersService); const remoteAgentHostService = accessor.get(IRemoteAgentHostService); @@ -67,6 +74,21 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { ? resolveRemoteAuthority(activeSession.providerId, sessionsProvidersService, remoteAgentHostService) : undefined; + if (environmentService.isBuilt && !isLinux) { + await this.launchViaSiblingApp(accessor, activeSession, folderUri, remoteAuthority); + } else { + await this.launchViaProtocolHandler(accessor, productService, activeSession, folderUri, remoteAuthority); + } + } + + private async launchViaSiblingApp( + accessor: ServicesAccessor, + activeSession: ReturnType, + folderUri: URI | undefined, + remoteAuthority: string | undefined, + ): Promise { + const nativeHostService = accessor.get(INativeHostService); + const args: string[] = ['--new-window']; if (folderUri) { @@ -83,6 +105,50 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { await nativeHostService.launchSiblingApp(args); } + + private async launchViaProtocolHandler( + accessor: ServicesAccessor, + productService: IProductService, + activeSession: ReturnType, + folderUri: URI | undefined, + remoteAuthority: string | undefined, + ): Promise { + const openerService = accessor.get(IOpenerService); + + const scheme = productService.quality === 'stable' + ? 'vscode' + : productService.quality === 'exploration' + ? 'vscode-exploration' + : productService.quality === 'insider' + ? 'vscode-insiders' + : productService.urlProtocol; + + const params = new URLSearchParams(); + params.set('windowId', '_blank'); + + if (!activeSession || !folderUri) { + await openerService.open(URI.from({ scheme, query: params.toString() }), { openExternal: true }); + return; + } + + params.set('session', activeSession.resource.toString()); + + if (remoteAuthority) { + await openerService.open(URI.from({ + scheme, + authority: Schemas.vscodeRemote, + path: `/${remoteAuthority}${folderUri.path}`, + query: params.toString(), + }), { openExternal: true }); + } else { + await openerService.open(URI.from({ + scheme, + authority: Schemas.file, + path: folderUri.path, + query: params.toString(), + }), { openExternal: true }); + } + } }); registerAction2(DebugAgentHostInDevToolsAction); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 052afa16a8373..f09d19059bd9d 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -4,12 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { timeout } from '../../../../../base/common/async.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; import { RemoteAgentHostConnectionStatus, IRemoteAgentHostService } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; @@ -26,7 +28,6 @@ import { ISessionsProvider } from '../../../../services/sessions/common/sessions import { IAgentHostSessionsProvider } from '../../../../common/agentHostSessionsProvider.js'; import { ISessionWorkspace } from '../../../../services/sessions/common/session.js'; import { WorkspacePicker, IWorkspaceSelection } from '../../browser/sessionWorkspacePicker.js'; -import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { IWorkspacesService } from '../../../../../platform/workspaces/common/workspaces.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; @@ -77,7 +78,11 @@ function createMockProvider(id: string, opts?: { getSessionConfigCompletions: async () => [], getCreateSessionConfig: () => undefined, clearSessionConfig: () => { }, - } as IAgentHostSessionsProvider; + onDidChangeRootConfig: Event.None, + getRootConfig: () => undefined, + setRootConfigValue: async () => { }, + replaceRootConfig: async () => { }, + } as unknown as IAgentHostSessionsProvider; } return base; } @@ -133,9 +138,6 @@ function createTestPicker( instantiationService.stub(IStorageService, storage); instantiationService.stub(IUriIdentityService, { extUri }); instantiationService.stub(ISessionsProvidersService, providersService); - instantiationService.stub(ISessionsManagementService, { - activeProviderId: observableValue('activeProviderId', undefined), - }); instantiationService.stub(IRemoteAgentHostService, {}); instantiationService.stub(IQuickInputService, {}); instantiationService.stub(IClipboardService, {}); @@ -175,7 +177,10 @@ suite('WorkspacePicker - Connection Status', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('restore skips unavailable (disconnected) provider', () => { + test('restore picks checked entry even when remote is disconnected (before grace period)', () => { + // Restore is honored synchronously: the picker shows the checked entry + // while we wait to see if the connection comes up. The grace-period + // fallback (covered in a separate test) only fires later. const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Disconnected); const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus }); const localProvider = createMockProvider('local-1'); @@ -189,12 +194,94 @@ suite('WorkspacePicker - Connection Status', () => { providersService.setProviders([remoteProvider, localProvider]); const picker = createTestPicker(disposables, providersService, storage); - // The checked entry is from a disconnected provider — should fall back to local - assertSelectedProvider(picker, 'local-1'); + assertSelectedProvider(picker, 'agenthost-remote-1'); }); - test('restore skips connecting provider', () => { - const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Connecting); + test('restored remote that never connects falls back after grace period', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + // The provider is registered as Disconnected and never transitions — + // e.g. SSH host is unreachable and the status was set before the picker + // could subscribe. The picker should fall back to no selection after + // the grace period so the view pane drops the stale session. + const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Disconnected); + const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus }); + + const storage = disposables.add(new TestStorageService()); + seedStorage(storage, [ + { uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true }, + ]); + + providersService.setProviders([remoteProvider]); + const picker = createTestPicker(disposables, providersService, storage); + + assertSelectedProvider(picker, 'agenthost-remote-1', 'Selection is restored synchronously'); + + const events: Array = []; + disposables.add(picker.onDidSelectWorkspace(e => events.push(e))); + + // Advance past the grace period. + await timeout(10_000); + + assertSelectedProvider(picker, undefined, 'Selection cleared after grace period'); + assert.deepStrictEqual(events, [undefined], 'onDidSelectWorkspace fired with undefined'); + })); + + test('restored remote that connects within grace period keeps selection', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Disconnected); + const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus }); + + const storage = disposables.add(new TestStorageService()); + seedStorage(storage, [ + { uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true }, + ]); + + providersService.setProviders([remoteProvider]); + const picker = createTestPicker(disposables, providersService, storage); + + // Connection succeeds quickly. + await timeout(100); + remoteStatus.set(RemoteAgentHostConnectionStatus.Connecting, undefined); + await timeout(500); + remoteStatus.set(RemoteAgentHostConnectionStatus.Connected, undefined); + + // Advance past the grace period — should not fall back since we connected. + await timeout(10_000); + + assertSelectedProvider(picker, 'agenthost-remote-1', 'Selection preserved after successful connect'); + })); + + test('user pick during connect cancels the fallback', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + // If the user picks a different workspace while the restore-grace-period + // timer is running, the timer must not later clear the user's selection. + const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Disconnected); + const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus }); + const localProvider = createMockProvider('local-1'); + + const storage = disposables.add(new TestStorageService()); + seedStorage(storage, [ + { uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true }, + ]); + + providersService.setProviders([remoteProvider, localProvider]); + const picker = createTestPicker(disposables, providersService, storage); + + // User picks a local workspace while the remote is still trying to connect. + const localPick: IWorkspaceSelection = { + providerId: 'local-1', + workspace: localProvider.resolveWorkspace(URI.file('/local/picked'))!, + }; + picker.setSelectedWorkspace(localPick, false); + + // Grace period elapses; remote still disconnected — must not affect user pick. + await timeout(10_000); + + assertSelectedProvider(picker, 'local-1', 'User pick preserved across grace-period elapse'); + })); + + test('restore picks checked entry while remote is connecting (no fallback flicker)', () => { + // SSH remote: provider registers in Disconnected state and immediately + // starts connecting. We restore the checked entry immediately rather than + // falling back to a different workspace and swapping later. + const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Disconnected); const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus }); const localProvider = createMockProvider('local-1'); @@ -207,11 +294,22 @@ suite('WorkspacePicker - Connection Status', () => { providersService.setProviders([remoteProvider, localProvider]); const picker = createTestPicker(disposables, providersService, storage); - assertSelectedProvider(picker, 'local-1'); + assertSelectedProvider(picker, 'agenthost-remote-1'); + + // Connection attempt starts (no fallback while connecting). + remoteStatus.set(RemoteAgentHostConnectionStatus.Connecting, undefined); + assertSelectedProvider(picker, 'agenthost-remote-1'); + + // After connection completes, selection is unchanged. + remoteStatus.set(RemoteAgentHostConnectionStatus.Connected, undefined); + assertSelectedProvider(picker, 'agenthost-remote-1'); }); - test('restore picks connected remote provider', () => { - const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Connected); + test('connecting provider that fails falls back to no selection', () => { + // Real SSH remote lifecycle: starts Disconnected, transitions Connecting, + // then fails back to Disconnected. The picker must clear the selection + // and fire onDidSelectWorkspace(undefined) so the view pane calls unsetNewSession(). + const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Disconnected); const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus }); const storage = disposables.add(new TestStorageService()); @@ -222,10 +320,23 @@ suite('WorkspacePicker - Connection Status', () => { providersService.setProviders([remoteProvider]); const picker = createTestPicker(disposables, providersService, storage); - assertSelectedProvider(picker, 'agenthost-remote-1'); + assertSelectedProvider(picker, 'agenthost-remote-1', 'Selection is restored while connecting'); + + const events: Array = []; + disposables.add(picker.onDidSelectWorkspace(e => events.push(e))); + + // SSH tunnel begins. + remoteStatus.set(RemoteAgentHostConnectionStatus.Connecting, undefined); + assertSelectedProvider(picker, 'agenthost-remote-1', 'Selection preserved while connecting'); + + // SSH tunnel fails. + remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined); + + assertSelectedProvider(picker, undefined, 'Selection cleared after connection failure'); + assert.deepStrictEqual(events, [undefined], 'onDidSelectWorkspace fired with undefined'); }); - test('disconnect clears selection from that provider', () => { + test('restore picks connected remote provider', () => { const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Connected); const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus }); @@ -236,14 +347,11 @@ suite('WorkspacePicker - Connection Status', () => { providersService.setProviders([remoteProvider]); const picker = createTestPicker(disposables, providersService, storage); - assertSelectedProvider(picker, 'agenthost-remote-1'); - // Disconnect - remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined); - assertSelectedProvider(picker, undefined, 'Selection should be cleared after disconnect'); + assertSelectedProvider(picker, 'agenthost-remote-1'); }); - test('reconnect restores the same workspace', () => { + test('disconnect preserves selection (renders grayed; no auto-clear)', () => { const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Connected); const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus }); @@ -256,40 +364,32 @@ suite('WorkspacePicker - Connection Status', () => { const picker = createTestPicker(disposables, providersService, storage); assertSelectedProvider(picker, 'agenthost-remote-1'); - // Disconnect — clears selection + // Disconnect — selection is preserved (the user picked it; we keep honoring it). remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined); - assertSelectedProvider(picker, undefined, 'Should clear on disconnect'); - - // Reconnect — should restore - remoteStatus.set(RemoteAgentHostConnectionStatus.Connected, undefined); - assertSelectedProvider(picker, 'agenthost-remote-1', 'Should restore after reconnect'); - assert.strictEqual( - picker.selectedProject?.workspace.repositories[0]?.uri.path, - '/remote/project', - 'Should restore the same workspace URI', - ); + assertSelectedProvider(picker, 'agenthost-remote-1', 'Selection should be preserved on disconnect'); }); - test('disconnect does not auto-select another provider workspace', () => { + test('reconnect keeps the selection (no extra event fires)', () => { const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Connected); const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus }); - const localProvider = createMockProvider('local-1'); const storage = disposables.add(new TestStorageService()); seedStorage(storage, [ { uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true }, - { uri: URI.file('/local/project'), providerId: 'local-1', checked: false }, ]); - providersService.setProviders([remoteProvider, localProvider]); + providersService.setProviders([remoteProvider]); const picker = createTestPicker(disposables, providersService, storage); assertSelectedProvider(picker, 'agenthost-remote-1'); - // Disconnect remote + // Disconnect / reconnect cycle — selection preserved throughout. remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined); - - // Should NOT auto-select local workspace — should remain empty - assertSelectedProvider(picker, undefined, 'Should not auto-select another provider on disconnect'); + remoteStatus.set(RemoteAgentHostConnectionStatus.Connected, undefined); + assertSelectedProvider(picker, 'agenthost-remote-1'); + assert.strictEqual( + picker.selectedProject?.workspace.repositories[0]?.uri.path, + '/remote/project', + ); }); test('checked is globally unique after persist', () => { @@ -324,45 +424,82 @@ suite('WorkspacePicker - Connection Status', () => { assert.strictEqual(checkedEntries[0].providerId, 'local-1', 'The local entry should be checked'); }); - test('onDidSelectWorkspace fires on reconnect restore', () => { - const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Connected); - const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus }); + test('local provider is never treated as unavailable', () => { + const localProvider = createMockProvider('local-1'); const storage = disposables.add(new TestStorageService()); seedStorage(storage, [ - { uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true }, + { uri: URI.file('/local/project'), providerId: 'local-1', checked: true }, ]); - providersService.setProviders([remoteProvider]); + providersService.setProviders([localProvider]); const picker = createTestPicker(disposables, providersService, storage); - const selected: IWorkspaceSelection[] = []; - disposables.add(picker.onDidSelectWorkspace(w => { - if (w) { - selected.push(w); - } - })); + assertSelectedProvider(picker, 'local-1', 'Local provider workspace should always be selectable'); + }); - // Disconnect then reconnect - remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined); - remoteStatus.set(RemoteAgentHostConnectionStatus.Connected, undefined); + test('restore picks the stored workspace when its provider registers after another provider', () => { + // Regression: previously the picker filtered restore through `activeProviderId`, + // which auto-locked to whichever provider registered first. If the stored + // workspace belonged to a provider that registered later than another available + // provider (for example, local-agent-host registering after default-copilot), + // the stored entry was filtered out and never restored. + // + // Realistic shape: storage holds BOTH a (non-checked) recent for the + // early-registering provider and a (checked) recent for the late-registering + // provider. The picker may briefly show the early recent as a fallback, but + // once the checked entry's provider registers, the picker must upgrade to it. + const copilotProvider = createMockProvider('default-copilot'); - assert.strictEqual(selected.length, 1, 'onDidSelectWorkspace should fire once on reconnect'); - assert.strictEqual(selected[0].providerId, 'agenthost-remote-1'); - assert.strictEqual(selected[0].workspace.repositories[0]?.uri.path, '/remote/project', 'Event should carry the correct workspace URI'); + const storage = disposables.add(new TestStorageService()); + seedStorage(storage, [ + { uri: URI.file('/copilot/old-project'), providerId: 'default-copilot', checked: false }, + { uri: URI.file('/agent-host/project'), providerId: 'local-agent-host', checked: true }, + ]); + + // Construct picker with only the early-registering provider available. + providersService.setProviders([copilotProvider]); + const picker = createTestPicker(disposables, providersService, storage); + + // The fallback may be selected initially (early provider's recent), + // since the user's checked entry's provider isn't ready yet. + // Now the late provider arrives. + const agentHostProvider = createMockProvider('local-agent-host'); + providersService.setProviders([copilotProvider, agentHostProvider]); + + assertSelectedProvider(picker, 'local-agent-host', 'Stored workspace should be restored once its provider registers'); }); - test('local provider is never treated as unavailable', () => { - const localProvider = createMockProvider('local-1'); + test('late-registering provider does not move selection out from under user', () => { + // After the user has explicitly picked a workspace, a provider + // registering later in the session must not switch the selection to its + // stored "checked" entry. We only do that auto-upgrade during initial + // startup before the user has acted. + const copilotProvider = createMockProvider('default-copilot'); const storage = disposables.add(new TestStorageService()); seedStorage(storage, [ - { uri: URI.file('/local/project'), providerId: 'local-1', checked: true }, + { uri: URI.file('/agent-host/project'), providerId: 'local-agent-host', checked: true }, ]); - providersService.setProviders([localProvider]); + providersService.setProviders([copilotProvider]); const picker = createTestPicker(disposables, providersService, storage); - assertSelectedProvider(picker, 'local-1', 'Local provider workspace should always be selectable'); + // Suppression kicked in: no fallback selection while checked entry is pending. + assertSelectedProvider(picker, undefined, 'No fallback while checked entry pending'); + + // User explicitly picks a Copilot workspace. + const copilotPick: IWorkspaceSelection = { + providerId: 'default-copilot', + workspace: copilotProvider.resolveWorkspace(URI.file('/copilot/picked'))!, + }; + picker.setSelectedWorkspace(copilotPick, false); + assertSelectedProvider(picker, 'default-copilot', 'User pick is honored'); + + // Now the late provider for the (still-stored) checked entry arrives. + const agentHostProvider = createMockProvider('local-agent-host'); + providersService.setProviders([copilotProvider, agentHostProvider]); + + assertSelectedProvider(picker, 'default-copilot', 'User selection is preserved across late provider registration'); }); }); diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts index d28853194f27e..af75eb0144c43 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -80,6 +80,9 @@ Registry.as(Extensions.Configuration).registerDefaultCon 'terminal.integrated.initialHint': false, + 'workbench.browser.openLocalhostLinks': true, + 'workbench.browser.enableChatTools': false, + 'workbench.editor.doubleClickTabToToggleEditorGroupSizes': 'maximize', 'workbench.editor.restoreEditors': false, 'update.showReleaseNotes': false, diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilter.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilter.contribution.ts index 45a8134f90a12..bf233a27c8807 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilter.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilter.contribution.ts @@ -12,6 +12,7 @@ import { IsWebContext } from '../../../../platform/contextkey/common/contextkeys import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IsNewChatSessionContext } from '../../../common/contextkeys.js'; import { Menus } from '../../../browser/menus.js'; import { IAgentHostFilterService } from '../common/agentHostFilter.js'; import { HostFilterActionViewItem } from './hostFilterActionViewItem.js'; @@ -46,6 +47,21 @@ registerAction2(class PickAgentHostFilterAction extends Action2 { IsAuxiliaryWindowContext.toNegated(), HasAgentHostsContext, ), + }, { + // On phone/mobile layouts the desktop titlebar is replaced + // by the MobileTitlebarPart. Surface the host picker in its + // center slot while a new (empty) chat session is active, + // so users can still switch hosts and connect from the + // home screen. + id: Menus.MobileTitleBarCenter, + group: 'navigation', + order: 0, + when: ContextKeyExpr.and( + IsWebContext, + IsAuxiliaryWindowContext.toNegated(), + HasAgentHostsContext, + IsNewChatSessionContext, + ), }], }); } @@ -79,6 +95,13 @@ class AgentHostFilterContribution extends Disposable implements IWorkbenchContri (action, _options, instaService) => instaService.createInstance(HostFilterActionViewItem, action), filterService.onDidChange, )); + + this._register(actionViewItemService.register( + Menus.MobileTitleBarCenter, + PICK_HOST_FILTER_ID, + (action, _options, instaService) => instaService.createInstance(HostFilterActionViewItem, action), + filterService.onDidChange, + )); } private _update(filterService: IAgentHostFilterService): void { diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilterActionViewItem.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilterActionViewItem.ts index a618abe464697..6ae4928685b99 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilterActionViewItem.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilterActionViewItem.ts @@ -5,6 +5,7 @@ import './media/hostFilter.css'; import * as dom from '../../../../base/browser/dom.js'; +import { Gesture, EventType as TouchEventType } from '../../../../base/browser/touch.js'; import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { BaseActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; @@ -74,22 +75,23 @@ export class HostFilterActionViewItem extends BaseActionViewItem { this._chevronElement = dom.append(this._dropdownElement, dom.$('span.agent-host-filter-chevron')); this._chevronElement.append(...renderLabelWithIcons(`$(${Codicon.chevronDown.id})`)); - this._register(dom.addDisposableListener(this._dropdownElement, dom.EventType.CLICK, e => { - if (!this._isInteractive()) { - return; - } - e.preventDefault(); - e.stopPropagation(); - this._showMenu(e); - })); + this._register(Gesture.addTarget(this._dropdownElement)); + for (const eventType of [dom.EventType.CLICK, TouchEventType.Tap]) { + this._register(dom.addDisposableListener(this._dropdownElement, eventType, e => { + if (!this._isInteractive()) { + return; + } + dom.EventHelper.stop(e, true); + this._showMenu(e); + })); + } this._register(dom.addDisposableListener(this._dropdownElement, dom.EventType.KEY_DOWN, e => { if (!this._isInteractive()) { return; } const event = new StandardKeyboardEvent(e); if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) { - e.preventDefault(); - e.stopPropagation(); + dom.EventHelper.stop(e, true); this._showMenu(e); } })); @@ -97,16 +99,17 @@ export class HostFilterActionViewItem extends BaseActionViewItem { // --- Connection button (right) ------------------------------------------ this._connectElement = dom.append(this.element, dom.$('div.agent-host-filter-connect')); - this._register(dom.addDisposableListener(this._connectElement, dom.EventType.CLICK, e => { - e.preventDefault(); - e.stopPropagation(); - this._onConnectClick(); - })); + this._register(Gesture.addTarget(this._connectElement)); + for (const eventType of [dom.EventType.CLICK, TouchEventType.Tap]) { + this._register(dom.addDisposableListener(this._connectElement, eventType, e => { + dom.EventHelper.stop(e, true); + this._onConnectClick(); + })); + } this._register(dom.addDisposableListener(this._connectElement, dom.EventType.KEY_DOWN, e => { const event = new StandardKeyboardEvent(e); if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) { - e.preventDefault(); - e.stopPropagation(); + dom.EventHelper.stop(e, true); this._onConnectClick(); } })); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/media/hostFilter.css b/src/vs/sessions/contrib/remoteAgentHost/browser/media/hostFilter.css index fb19b063af93f..8286dc80341f7 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/media/hostFilter.css +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/media/hostFilter.css @@ -5,8 +5,12 @@ /* Compound widget (dropdown pill + connect button). Expands to fill the * space available in the titlebar's left toolbar after the sidebar toggle, - * so the pill + connect button appear centered in the remaining width. */ -.agent-host-filter-combo { + * so the pill + connect button appear centered in the remaining width. + * + * Higher specificity than the default `.monaco-action-bar .action-item` + * (which sets `display: block`) so the li lays out its two child controls + * (dropdown + connect) as a horizontal flex row. */ +.monaco-action-bar .action-item.agent-host-filter-combo { display: flex; align-items: center; justify-content: center; diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index df0ddee4e0ae2..959c2e4b72a88 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -10,6 +10,7 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { basename, dirname } from '../../../../base/common/resources.js'; import { IObservable, observableValue } from '../../../../base/common/observable.js'; +import { isWeb } from '../../../../base/common/platform.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; @@ -145,6 +146,14 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid private readonly _onDidDisconnect = this._register(new Emitter()); protected override get onConnectionLost(): Event { return this._onDidDisconnect.event; } + /** + * Overridable seam so tests can exercise both the web and non-web + * branches of the label/description gating without depending on the + * ambient {@link isWeb} constant (the browser test runner always + * reports `isWeb === true`). + */ + protected get isWebPlatform(): boolean { return isWeb; } + private _connection: IAgentConnection | undefined; private _defaultDirectory: string | undefined; private readonly _connectionListeners = this._register(new DisposableStore()); @@ -246,12 +255,13 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid } protected _adapterOptions() { + const web = this.isWebPlatform; return { - description: new MarkdownString().appendText(this.label), + description: web ? undefined : new MarkdownString().appendText(this.label), buildWorkspace: (project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined) => { const uriForDescription = project?.uri ?? workingDirectory; const description = uriForDescription ? this._labelService.getUriLabel(dirname(uriForDescription), { relative: false }) : undefined; - return buildAgentHostSessionWorkspace(project, workingDirectory, { providerLabel: this.label, fallbackIcon: Codicon.remote, requiresWorkspaceTrust: false, description }); + return buildAgentHostSessionWorkspace(project, workingDirectory, { providerLabel: web ? undefined : this.label, fallbackIcon: Codicon.remote, requiresWorkspaceTrust: false, description }); }, }; } @@ -361,9 +371,11 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid const rootStateValue = connection.rootState.value; if (rootStateValue && !(rootStateValue instanceof Error)) { this._syncSessionTypesFromRootState(rootStateValue); + this._syncRootConfigFromRootState(rootStateValue); } this._connectionListeners.add(connection.rootState.onDidChange(rootState => { this._syncSessionTypesFromRootState(rootState); + this._syncRootConfigFromRootState(rootState); })); this._attachConnectionListeners(connection, this._connectionListeners); @@ -496,7 +508,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid private _buildWorkspaceFromUri(uri: URI): ISessionWorkspace { const folderName = basename(uri) || uri.path; return { - label: `${folderName} [${this.label}]`, + label: this.isWebPlatform ? folderName : `${folderName} [${this.label}]`, description: this._labelService.getUriLabel(dirname(uri), { relative: false }), group: this.label, icon: Codicon.remote, diff --git a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 7799f9eabbe48..83487264714ec 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -12,7 +12,7 @@ import { mock } from '../../../../../base/test/common/mock.js'; import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { AgentSession, type IAgentConnection, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js'; -import type { SessionAction, TerminalAction } from '../../../../../platform/agentHost/common/state/protocol/action-origin.generated.js'; +import type { RootAction, SessionAction, TerminalAction } from '../../../../../platform/agentHost/common/state/protocol/action-origin.generated.js'; import type { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { NotificationType } from '../../../../../platform/agentHost/common/state/protocol/notifications.js'; import { SessionLifecycle, type AgentInfo, type ModelSelection, type RootState, type SessionConfigState, type SessionState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -49,7 +49,7 @@ class MockAgentConnection extends mock() { override readonly clientId = 'test-client-1'; private readonly _sessions = new Map(); public disposedSessions: URI[] = []; - public dispatchedActions: { action: SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = []; + public dispatchedActions: { action: RootAction | SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = []; public failResolveSessionConfig = false; public resolveSessionConfigResult: ResolveSessionConfigResult = { schema: { type: 'object', properties: {} }, values: { isolation: 'worktree' } }; @@ -89,11 +89,11 @@ class MockAgentConnection extends mock() { return this.resolveSessionConfigResult; } - dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void { + dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void { this.dispatchedActions.push({ action, clientId, clientSeq }); } - override dispatch(action: SessionAction | TerminalAction): void { + override dispatch(action: RootAction | SessionAction | TerminalAction): void { this.dispatchedActions.push({ action, clientId: this.clientId, clientSeq: this._nextSeq++ }); } @@ -177,7 +177,7 @@ function createSession(id: string, opts?: { provider?: string; summary?: string; }; } -function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; connectionName?: string | undefined; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean; storageService?: IStorageService; noConnection?: boolean }): RemoteAgentHostSessionsProvider { +function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; connectionName?: string | undefined; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean; storageService?: IStorageService; noConnection?: boolean; isWebPlatform?: boolean }): RemoteAgentHostSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IFileDialogService, {}); @@ -206,7 +206,12 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne name: overrides !== undefined && Object.prototype.hasOwnProperty.call(overrides, 'connectionName') ? overrides.connectionName ?? '' : 'Test Host', }; - const provider = disposables.add(instantiationService.createInstance(RemoteAgentHostSessionsProvider, config)); + const providerCtor = overrides?.isWebPlatform !== undefined + ? class extends RemoteAgentHostSessionsProvider { + protected override get isWebPlatform(): boolean { return overrides.isWebPlatform!; } + } + : RemoteAgentHostSessionsProvider; + const provider = disposables.add(instantiationService.createInstance(providerCtor, config)); if (!overrides?.noConnection) { provider.setConnection(connection); } @@ -312,12 +317,12 @@ suite('RemoteAgentHostSessionsProvider', () => { // ---- Workspace resolution ------- test('resolveWorkspace builds workspace from URI', () => { - const provider = createProvider(disposables, connection); + const provider = createProvider(disposables, connection, { isWebPlatform: true }); const uri = URI.parse('vscode-agent-host://auth/home/user/project'); const ws = provider.resolveWorkspace(uri); assert.ok(ws, 'resolveWorkspace should resolve vscode-agent-host:// URIs'); - assert.strictEqual(ws.label, 'project [Test Host]'); + assert.strictEqual(ws.label, 'project'); assert.strictEqual(ws.repositories.length, 1); assert.strictEqual(ws.repositories[0].uri.toString(), uri.toString()); assert.strictEqual(ws.repositories[0].detail, undefined); @@ -400,7 +405,7 @@ suite('RemoteAgentHostSessionsProvider', () => { workingDirectory, })); - const provider = createProvider(disposables, connection); + const provider = createProvider(disposables, connection, { isWebPlatform: true }); provider.getSessions(); await timeout(0); @@ -411,7 +416,7 @@ suite('RemoteAgentHostSessionsProvider', () => { workingDirectory: workspace?.repositories[0]?.workingDirectory?.toString(), detail: workspace?.repositories[0]?.detail, }, { - label: 'vscode [Test Host]', + label: 'vscode', repository: projectUri.toString(), workingDirectory: workingDirectory.toString(), detail: undefined, @@ -521,13 +526,13 @@ suite('RemoteAgentHostSessionsProvider', () => { // ---- Session lifecycle ------- test('createNewSession returns session with correct fields', () => { - const provider = createProvider(disposables, connection); + const provider = createProvider(disposables, connection, { isWebPlatform: true }); const session = provider.createNewSession(URI.parse('vscode-agent-host://auth/home/user/project'), provider.sessionTypes[0].id); assert.strictEqual(session.providerId, provider.id); assert.strictEqual(session.status.get(), SessionStatus.Untitled); assert.ok(session.workspace.get()); - assert.strictEqual(session.workspace.get()?.label, 'project [Test Host]'); + assert.strictEqual(session.workspace.get()?.label, 'project'); // sessionType should be the logical type, not the resource scheme assert.strictEqual(session.sessionType, provider.sessionTypes[0].id); assert.deepStrictEqual(provider.getSessionConfig(session.sessionId), { schema: { type: 'object', properties: {} }, values: {} }); @@ -535,7 +540,7 @@ suite('RemoteAgentHostSessionsProvider', () => { test('createNewSession clears session config when resolving config is unavailable', async () => { connection.failResolveSessionConfig = true; - const provider = createProvider(disposables, connection); + const provider = createProvider(disposables, connection, { isWebPlatform: true }); const workspaceUri = URI.parse('vscode-agent-host://auth/home/user/project'); const session = provider.createNewSession(workspaceUri, provider.sessionTypes[0].id); const resolved = provider.getSessionByResource(session.resource); @@ -547,7 +552,7 @@ suite('RemoteAgentHostSessionsProvider', () => { }, { listedSessions: 0, resolvedResource: session.resource.toString(), - resolvedWorkspaceLabel: 'project [Test Host]', + resolvedWorkspaceLabel: 'project', }); }); @@ -855,7 +860,7 @@ suite('RemoteAgentHostSessionsProvider', () => { test('session adapter has correct workspace from working directory', () => runWithFakedTimers({ useFakeTimers: true }, async () => { connection.addSession(createSession('ws-sess', { summary: 'WS Test', workingDirectory: URI.parse('vscode-agent-host://localhost__4321/file/-/home/user/myrepo') })); - const provider = createProvider(disposables, connection); + const provider = createProvider(disposables, connection, { isWebPlatform: true }); provider.getSessions(); await timeout(0); @@ -865,7 +870,7 @@ suite('RemoteAgentHostSessionsProvider', () => { const workspace = wsSession!.workspace.get(); assert.ok(workspace, 'Workspace should be populated'); - assert.strictEqual(workspace!.label, 'myrepo [Test Host]'); + assert.strictEqual(workspace!.label, 'myrepo'); assert.strictEqual(workspace!.repositories[0].detail, undefined); })); @@ -1008,4 +1013,82 @@ suite('RemoteAgentHostSessionsProvider', () => { assert.strictEqual(connection.sessionUnsubscribeCounts.get(sessionUriStr), 1); })); + // ---- Non-web label formatting (native desktop) ------- + // + // In the browser test runner `isWeb` is always `true`, so by default + // every test above exercises the web branch (which drops the + // `[]` suffix because the titlebar host filter renders it + // redundantly). These tests pin the non-web (desktop) behaviour where + // the host suffix / host description must still appear. + + test('non-web: resolveWorkspace includes [host] suffix in label', () => { + const provider = createProvider(disposables, connection, { isWebPlatform: false }); + const uri = URI.parse('vscode-agent-host://auth/home/user/project'); + const ws = provider.resolveWorkspace(uri); + + assert.ok(ws); + assert.strictEqual(ws.label, 'project [Test Host]'); + }); + + test('non-web: session workspace from project metadata includes [host] suffix', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const projectUri = URI.parse('vscode-agent-host://localhost__4321/file/-/home/user/vscode'); + connection.addSession(createSession('project-1', { + summary: 'Project Session', + project: { uri: projectUri, displayName: 'vscode' }, + })); + + const provider = createProvider(disposables, connection, { isWebPlatform: false }); + provider.getSessions(); + await timeout(0); + + assert.strictEqual(provider.getSessions()[0].workspace.get()?.label, 'vscode [Test Host]'); + })); + + test('non-web: session workspace from working directory includes [host] suffix', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + connection.addSession(createSession('ws-sess', { + summary: 'WS Test', + workingDirectory: URI.parse('vscode-agent-host://localhost__4321/file/-/home/user/myrepo'), + })); + + const provider = createProvider(disposables, connection, { isWebPlatform: false }); + provider.getSessions(); + await timeout(0); + + const wsSession = provider.getSessions().find(s => s.title.get() === 'WS Test'); + assert.strictEqual(wsSession?.workspace.get()?.label, 'myrepo [Test Host]'); + })); + + test('non-web: createNewSession workspace label includes [host] suffix', () => { + const provider = createProvider(disposables, connection, { isWebPlatform: false }); + const session = provider.createNewSession(URI.parse('vscode-agent-host://auth/home/user/project'), provider.sessionTypes[0].id); + + assert.strictEqual(session.workspace.get()?.label, 'project [Test Host]'); + }); + + test('non-web: session description is the host label', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + connection.addSession(createSession('desc-sess', { summary: 'Desc Test' })); + + const provider = createProvider(disposables, connection, { isWebPlatform: false }); + provider.getSessions(); + await timeout(0); + + const session = provider.getSessions().find(s => s.title.get() === 'Desc Test'); + const description = session?.description.get(); + assert.ok(description, 'description should be defined on non-web'); + // MarkdownString.appendText escapes spaces as   — verify the + // host label is present rather than the exact serialized form. + assert.ok(description!.value.includes('Test') && description!.value.includes('Host')); + })); + + test('web: session description is undefined (host filter dropdown replaces it)', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + connection.addSession(createSession('desc-sess-web', { summary: 'Desc Web' })); + + const provider = createProvider(disposables, connection, { isWebPlatform: true }); + provider.getSessions(); + await timeout(0); + + const session = provider.getSessions().find(s => s.title.get() === 'Desc Web'); + assert.strictEqual(session?.description.get(), undefined); + })); + }); diff --git a/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts b/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts index cd2bbee6e27b3..cf696435464c1 100644 --- a/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts @@ -21,8 +21,10 @@ import { IPromptsService } from '../../../../workbench/contrib/chat/common/promp import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { Menus } from '../../../browser/menus.js'; -import { getCustomizationTotalCount } from './customizationCounts.js'; +import { getCustomizationTotalCount, getActiveItemProvider } from './customizationCounts.js'; import { IAgentPluginService } from '../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; +import { ICustomizationHarnessService } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; const $ = DOM.$; @@ -46,6 +48,8 @@ export class AICustomizationShortcutsWidget extends Disposable { @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, @IAgentPluginService private readonly agentPluginService: IAgentPluginService, + @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, ) { super(); @@ -101,9 +105,10 @@ export class AICustomizationShortcutsWidget extends Disposable { })); let updateCountRequestId = 0; + const updateHeaderTotalCount = async () => { const requestId = ++updateCountRequestId; - const totalCount = await getCustomizationTotalCount(this.promptsService, this.mcpService, this.workspaceService, this.workspaceContextService, this.agentPluginService); + const totalCount = await getCustomizationTotalCount(this.promptsService, this.mcpService, this.workspaceService, this.workspaceContextService, this.agentPluginService, getActiveItemProvider(this.sessionsManagementService, this.harnessService)); if (requestId !== updateCountRequestId) { return; } @@ -123,6 +128,15 @@ export class AICustomizationShortcutsWidget extends Disposable { this.workspaceService.activeProjectRoot.read(reader); updateHeaderTotalCount(); })); + this._register(autorun(reader => { + this.sessionsManagementService.activeSession.read(reader); + this.harnessService.availableHarnesses.read(reader); + const provider = getActiveItemProvider(this.sessionsManagementService, this.harnessService); + if (provider) { + reader.store.add(provider.onDidChange(() => updateHeaderTotalCount())); + } + updateHeaderTotalCount(); + })); updateHeaderTotalCount(); // Toggle collapse on header click diff --git a/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts index 9c30d64c313b4..90c1b0719c136 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts @@ -15,7 +15,9 @@ import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.j import { IAICustomizationWorkspaceService, applyStorageSourceFilter, IStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { parseHooksFromFile } from '../../../../workbench/contrib/chat/common/promptSyntax/hookCompatibility.js'; import { IAgentPluginService } from '../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; +import { ICustomizationHarnessService, ICustomizationItemProvider } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { parse as parseJSONC } from '../../../../base/common/jsonc.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; export interface ISourceCounts { readonly workspace: number; @@ -136,19 +138,41 @@ export async function getSourceCounts( }; } +const PROMPT_TYPES: PromptsType[] = [PromptsType.agent, PromptsType.skill, PromptsType.instructions, PromptsType.hook]; +const PROMPT_TYPE_SET = new Set(PROMPT_TYPES); + export async function getCustomizationTotalCount( promptsService: IPromptsService, mcpService: IMcpService, workspaceService: IAICustomizationWorkspaceService, workspaceContextService: IWorkspaceContextService, agentPluginService?: IAgentPluginService, + itemProvider?: ICustomizationItemProvider, ): Promise { - const types: PromptsType[] = [PromptsType.agent, PromptsType.skill, PromptsType.instructions, PromptsType.hook]; - const results = await Promise.all(types.map(type => { - const filter = workspaceService.getStorageSourceFilter(type); - return getSourceCounts(promptsService, type, filter, workspaceContextService, workspaceService) - .then(counts => getSourceCountsTotal(counts, filter)); - })); + let promptTotal: number; + if (itemProvider) { + const allItems = await itemProvider.provideChatSessionCustomizations(CancellationToken.None); + promptTotal = allItems?.filter(item => PROMPT_TYPE_SET.has(item.type)).length ?? 0; + } else { + const results = await Promise.all(PROMPT_TYPES.map(type => { + const filter = workspaceService.getStorageSourceFilter(type); + return getSourceCounts(promptsService, type, filter, workspaceContextService, workspaceService) + .then(counts => getSourceCountsTotal(counts, filter)); + })); + promptTotal = results.reduce((sum, n) => sum + n, 0); + } + const pluginCount = agentPluginService?.plugins.get().length ?? 0; - return results.reduce((sum, n) => sum + n, 0) + mcpService.servers.get().length + pluginCount; + return promptTotal + mcpService.servers.get().length + pluginCount; +} + +export function getActiveItemProvider( + sessionsManagementService: ISessionsManagementService, + harnessService: ICustomizationHarnessService, +): ICustomizationItemProvider | undefined { + const sessionType = sessionsManagementService.activeSession.get()?.sessionType; + if (sessionType) { + return harnessService.findHarnessById(sessionType)?.itemProvider; + } + return undefined; } diff --git a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts index e0d453627eab0..c4062dc948f2c 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts @@ -5,6 +5,7 @@ import '../../../browser/media/sidebarActionButton.css'; import './media/customizationsToolbar.css'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize, localize2 } from '../../../../nls.js'; @@ -12,6 +13,7 @@ import { Action2, registerAction2 } from '../../../../platform/actions/common/ac import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { AICustomizationManagementEditor } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; import { AICustomizationManagementEditorInput } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; @@ -27,16 +29,18 @@ import { IWorkspaceContextService } from '../../../../platform/workspace/common/ import { IFileService } from '../../../../platform/files/common/files.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; -import { getSourceCounts, getSourceCountsTotal } from './customizationCounts.js'; +import { getSourceCounts, getSourceCountsTotal, getActiveItemProvider } from './customizationCounts.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; -import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection, IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IAgentPluginService } from '../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; +import { ICustomizationHarnessService } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; export interface ICustomizationItemConfig { readonly id: string; readonly label: string; readonly icon: ThemeIcon; + readonly section: typeof AICustomizationManagementSection[keyof typeof AICustomizationManagementSection]; readonly promptType?: PromptsType; readonly isMcp?: boolean; readonly isPlugins?: boolean; @@ -47,36 +51,42 @@ export const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ id: 'sessions.customization.agents', label: localize('agents', "Agents"), icon: agentIcon, + section: AICustomizationManagementSection.Agents, promptType: PromptsType.agent, }, { id: 'sessions.customization.skills', label: localize('skills', "Skills"), icon: skillIcon, + section: AICustomizationManagementSection.Skills, promptType: PromptsType.skill, }, { id: 'sessions.customization.instructions', label: localize('instructions', "Instructions"), icon: instructionsIcon, + section: AICustomizationManagementSection.Instructions, promptType: PromptsType.instructions, }, { id: 'sessions.customization.hooks', label: localize('hooks', "Hooks"), icon: hookIcon, + section: AICustomizationManagementSection.Hooks, promptType: PromptsType.hook, }, { id: 'sessions.customization.mcpServers', label: localize('mcpServers', "MCP Servers"), icon: mcpServerIcon, + section: AICustomizationManagementSection.McpServers, isMcp: true, }, { id: 'sessions.customization.plugins', label: localize('plugins', "Plugins"), icon: pluginIcon, + section: AICustomizationManagementSection.Plugins, isPlugins: true, }, ]; @@ -103,6 +113,7 @@ export class CustomizationLinkViewItem extends ActionViewItem { @IAICustomizationWorkspaceService private readonly _workspaceService: IAICustomizationWorkspaceService, @IFileService private readonly _fileService: IFileService, @IAgentPluginService private readonly _agentPluginService: IAgentPluginService, + @ICustomizationHarnessService private readonly _harnessService: ICustomizationHarnessService, ) { super(undefined, action, { ...options, icon: false, label: false }); this._viewItemDisposables = this._register(new DisposableStore()); @@ -153,6 +164,11 @@ export class CustomizationLinkViewItem extends ActionViewItem { this._viewItemDisposables.add(this._workspaceContextService.onDidChangeWorkspaceFolders(() => this._updateCounts())); this._viewItemDisposables.add(autorun(reader => { this._activeSessionService.activeSession.read(reader); + this._harnessService.availableHarnesses.read(reader); + const provider = getActiveItemProvider(this._activeSessionService, this._harnessService); + if (provider) { + reader.store.add(provider.onDidChange(() => this._updateCounts())); + } this._updateCounts(); })); @@ -168,16 +184,26 @@ export class CustomizationLinkViewItem extends ActionViewItem { } const requestId = ++this._updateCountsRequestId; + const itemProvider = getActiveItemProvider(this._activeSessionService, this._harnessService); if (this._config.promptType) { - const type = this._config.promptType; - const filter = this._workspaceService.getStorageSourceFilter(type); - const counts = await getSourceCounts(this._promptsService, type, filter, this._workspaceContextService, this._workspaceService, this._fileService); - if (requestId !== this._updateCountsRequestId) { - return; + if (itemProvider) { + const allItems = await itemProvider.provideChatSessionCustomizations(CancellationToken.None); + if (requestId !== this._updateCountsRequestId) { + return; + } + const total = allItems?.filter(item => item.type === this._config.promptType).length ?? 0; + this._renderTotalCount(this._countContainer, total); + } else { + const type = this._config.promptType; + const filter = this._workspaceService.getStorageSourceFilter(type); + const counts = await getSourceCounts(this._promptsService, type, filter, this._workspaceContextService, this._workspaceService, this._fileService); + if (requestId !== this._updateCountsRequestId) { + return; + } + const total = getSourceCountsTotal(counts, filter); + this._renderTotalCount(this._countContainer, total); } - const total = getSourceCountsTotal(counts, filter); - this._renderTotalCount(this._countContainer, total); } else if (this._config.isMcp) { const total = this._mcpService.servers.get().length; this._renderTotalCount(this._countContainer, total); @@ -231,8 +257,17 @@ export class CustomizationsToolbarContribution extends Disposable implements IWo } async run(accessor: ServicesAccessor): Promise { const editorService = accessor.get(IEditorService); + const harnessService = accessor.get(ICustomizationHarnessService); + const sessionsManagementService = accessor.get(ISessionsManagementService); + const activeSessionType = sessionsManagementService.activeSession.get()?.sessionType; + if (activeSessionType && harnessService.findHarnessById(activeSessionType)) { + harnessService.setActiveHarness(activeSessionType); + } const input = AICustomizationManagementEditorInput.getOrCreate(); - await editorService.openEditor(input, { pinned: true }); + const pane = await editorService.openEditor(input, { pinned: true }); + if (pane instanceof AICustomizationManagementEditor) { + pane.selectSectionById(config.section); + } } })); } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index beb6c441354eb..a174012761e2f 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -70,7 +70,7 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { ) { super(undefined, action, options); - // Re-render when the active session, its data, or the active provider changes + // Re-render when the active session or its data changes this._register(autorun(reader => { const sessionData = this.sessionsManagementService.activeSession.read(reader); if (sessionData) { @@ -78,7 +78,6 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { sessionData.status.read(reader); sessionData.workspace.read(reader); } - this.sessionsManagementService.activeProviderId.read(reader); this._lastRenderState = undefined; this._render(); })); diff --git a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts index 1b67d7808761e..7c06c93808544 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts @@ -21,6 +21,7 @@ import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/co import { IMcpServer, IMcpService } from '../../../../../workbench/contrib/mcp/common/mcpTypes.js'; import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IAgentPluginService } from '../../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; +import { ICustomizationHarnessService } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; import { AICustomizationShortcutsWidget } from '../../browser/aiCustomizationShortcutsWidget.js'; import { CUSTOMIZATION_ITEMS, CustomizationLinkViewItem } from '../../browser/customizationsToolbar.contribution.js'; @@ -204,6 +205,10 @@ function renderWidget(ctx: ComponentFixtureContext, options?: { mcpServerCount?: reg.defineInstance(ISessionsManagementService, new class extends mock() { override readonly activeSession = observableValue('activeSession', undefined); }()); + reg.defineInstance(ICustomizationHarnessService, new class extends mock() { + override readonly availableHarnesses = observableValue('availableHarnesses', []); + override findHarnessById() { return undefined; } + }()); reg.defineInstance(IFileService, new class extends mock() { override readonly onDidFilesChange = Event.None; }()); diff --git a/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts b/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts index 2e4afacb53c13..3bb2f7738ec92 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts @@ -10,10 +10,14 @@ import { PromptsType } from '../../../../../workbench/contrib/chat/common/prompt import { IPromptsService, PromptsStorage, IPromptPath, ILocalPromptPath, IUserPromptPath, IExtensionPromptPath, IAgentInstructionFile, AgentInstructionFileType } from '../../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IWorkspaceContextService, IWorkspace, IWorkspaceFolder, WorkbenchState } from '../../../../../platform/workspace/common/workspace.js'; -import { getSourceCounts, getSourceCountsTotal, getCustomizationTotalCount } from '../../browser/customizationCounts.js'; +import { getSourceCounts, getSourceCountsTotal, getCustomizationTotalCount, getActiveItemProvider } from '../../browser/customizationCounts.js'; import { IMcpService } from '../../../../../workbench/contrib/mcp/common/mcpTypes.js'; import { Event } from '../../../../../base/common/event.js'; import { observableValue } from '../../../../../base/common/observable.js'; +import { ICustomizationHarnessService, ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; +import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { IAgentPluginService } from '../../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; function localFile(path: string): ILocalPromptPath { return { uri: URI.file(path), storage: PromptsStorage.local, type: PromptsType.instructions }; @@ -691,6 +695,148 @@ suite('customizationCounts', () => { }); }); + suite('getActiveItemProvider', () => { + function createMockSessionsService(sessionType?: string): ISessionsManagementService { + const activeSession = observableValue( + 'test', + sessionType ? { sessionType } as IActiveSession : undefined, + ); + return { activeSession } as unknown as ISessionsManagementService; + } + + function createMockHarnessService(harnesses: { id: string; itemProvider?: ICustomizationItemProvider }[]): ICustomizationHarnessService { + return { + findHarnessById: (sessionType: string) => { + const h = harnesses.find(h => h.id === sessionType); + return h ? { id: h.id, itemProvider: h.itemProvider } as IHarnessDescriptor : undefined; + }, + } as unknown as ICustomizationHarnessService; + } + + test('returns undefined when no active session', () => { + const sessionsService = createMockSessionsService(undefined); + const harnessService = createMockHarnessService([]); + assert.strictEqual(getActiveItemProvider(sessionsService, harnessService), undefined); + }); + + test('returns undefined when session type has no matching harness', () => { + const sessionsService = createMockSessionsService('unknown-type'); + const harnessService = createMockHarnessService([{ id: 'copilotcli' }]); + assert.strictEqual(getActiveItemProvider(sessionsService, harnessService), undefined); + }); + + test('returns undefined when harness has no itemProvider', () => { + const sessionsService = createMockSessionsService('copilotcli'); + const harnessService = createMockHarnessService([{ id: 'copilotcli', itemProvider: undefined }]); + assert.strictEqual(getActiveItemProvider(sessionsService, harnessService), undefined); + }); + + test('returns the itemProvider when harness exists with one', () => { + const mockProvider: ICustomizationItemProvider = { + onDidChange: Event.None, + provideChatSessionCustomizations: async () => [], + }; + const sessionsService = createMockSessionsService('claude-code'); + const harnessService = createMockHarnessService([{ id: 'claude-code', itemProvider: mockProvider }]); + assert.strictEqual(getActiveItemProvider(sessionsService, harnessService), mockProvider); + }); + }); + + suite('getCustomizationTotalCount with itemProvider', () => { + function createItemProvider(items: ICustomizationItem[]): ICustomizationItemProvider { + return { + onDidChange: Event.None, + provideChatSessionCustomizations: async (_token: CancellationToken) => items, + }; + } + + function makeItem(type: string, name: string): ICustomizationItem { + return { uri: URI.file(`/mock/${name}`), type, name, extensionId: undefined, pluginUri: undefined }; + } + + test('uses itemProvider counts when provided', async () => { + const promptsService = createMockPromptsService({}); + const mcpService = { + servers: observableValue('test', [{ id: 's1' }]), + } as unknown as IMcpService; + const workspaceService = createMockWorkspaceService({ filter: { sources: [PromptsStorage.local] } }); + const contextService = createMockWorkspaceContextService([]); + + const provider = createItemProvider([ + makeItem('agent', 'my-agent'), + makeItem('skill', 'my-skill'), + makeItem('instructions', 'my-instruction'), + makeItem('hook', 'my-hook'), + ]); + + const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService, undefined, provider); + + // 4 from provider + 1 mcp = 5 + assert.strictEqual(total, 5); + }); + + test('ignores non-prompt types from itemProvider', async () => { + const promptsService = createMockPromptsService({}); + const mcpService = { + servers: observableValue('test', []), + } as unknown as IMcpService; + const workspaceService = createMockWorkspaceService({ filter: { sources: [PromptsStorage.local] } }); + const contextService = createMockWorkspaceContextService([]); + + const provider = createItemProvider([ + makeItem('agent', 'a'), + makeItem('unknown-type', 'x'), + makeItem('prompt', 'p'), + ]); + + const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService, undefined, provider); + + // Only 'agent' matches the prompt types (agent, skill, instructions, hook) + assert.strictEqual(total, 1); + }); + + test('itemProvider returning undefined counts as zero', async () => { + const promptsService = createMockPromptsService({}); + const mcpService = { + servers: observableValue('test', [{ id: 's1' }, { id: 's2' }]), + } as unknown as IMcpService; + const workspaceService = createMockWorkspaceService({ filter: { sources: [PromptsStorage.local] } }); + const contextService = createMockWorkspaceContextService([]); + + const provider: ICustomizationItemProvider = { + onDidChange: Event.None, + provideChatSessionCustomizations: async () => undefined, + }; + + const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService, undefined, provider); + + // 0 from provider + 2 mcp = 2 + assert.strictEqual(total, 2); + }); + + test('sums itemProvider counts with plugins and mcp', async () => { + const promptsService = createMockPromptsService({}); + const mcpService = { + servers: observableValue('test', [{ id: 's1' }]), + } as unknown as IMcpService; + const workspaceService = createMockWorkspaceService({ filter: { sources: [PromptsStorage.local] } }); + const contextService = createMockWorkspaceContextService([]); + + const provider = createItemProvider([ + makeItem('agent', 'a'), + makeItem('skill', 's'), + ]); + const agentPluginService = { + plugins: observableValue('test', [{ id: 'p1' }, { id: 'p2' }, { id: 'p3' }]), + } as unknown as IAgentPluginService; + + const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService, agentPluginService, provider); + + // 2 from provider + 1 mcp + 3 plugins = 6 + assert.strictEqual(total, 6); + }); + }); + suite('data source consistency', () => { // These tests verify that getSourceCounts uses the same data sources // as the list widget's loadItems() — the root cause of the count mismatch bug. diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index b2b5193c2257d..3ae35fd5750df 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -22,7 +22,6 @@ import { IChat, ISession, isWorkspaceAgentSessionType, SessionStatus, ISessionTy import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; const ACTIVE_SESSION_STATES_KEY = 'agentSessions.activeSessionStates'; -const ACTIVE_PROVIDER_KEY = 'sessions.activeProviderId'; /** * Persisted state for a session. @@ -52,9 +51,6 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen private readonly _activeSession = observableValue(this, undefined); readonly activeSession: IObservable = this._activeSession; - private readonly _activeProviderId = observableValue(this, undefined); - readonly activeProviderId: IObservable = this._activeProviderId; - /** Tracks the pending new session so it can be restored by {@link openNewSessionView}. */ private _pendingNewSession: ISession | undefined; private readonly isNewChatSessionContext: IContextKey; @@ -96,11 +92,9 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen // Save on shutdown this._register(this.storageService.onWillSaveState(() => this._saveSessionStates())); - // Restore or auto-select active provider - this._initActiveProvider(); + // Subscribe to provider changes for session type updates this._register(this.sessionsProvidersService.onDidChangeProviders(e => { this._onProvidersChanged(e); - this._initActiveProvider(); this._updateSessionTypes(); })); this._subscribeToProviders(this.sessionsProvidersService.getProviders()); @@ -129,34 +123,6 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen } } - private _initActiveProvider(): void { - const providers = this.sessionsProvidersService.getProviders(); - if (providers.length === 0) { - return; - } - - // If already set and still valid, keep it - const current = this._activeProviderId.get(); - if (current && providers.some(p => p.id === current)) { - return; - } - - // Try to restore from storage - const stored = this.storageService.get(ACTIVE_PROVIDER_KEY, StorageScope.PROFILE); - if (stored && providers.some(p => p.id === stored)) { - this._activeProviderId.set(stored, undefined); - return; - } - - // Auto-select the first (or only) provider - this._activeProviderId.set(providers[0].id, undefined); - } - - setActiveProvider(providerId: string): void { - this._activeProviderId.set(providerId, undefined); - this.storageService.store(ACTIVE_PROVIDER_KEY, providerId, StorageScope.PROFILE, StorageTarget.MACHINE); - } - private onDidReplaceSession(from: ISession, to: ISession): void { if (this._activeSession.get()?.sessionId === from.sessionId) { this.setActiveSession(to); diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index ae3525813c12a..64ad47c3dcfb3 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -73,17 +73,6 @@ export interface ISessionsManagementService { */ readonly activeSession: IObservable; - /** - * Observable for the currently active sessions provider ID. - * When only one provider exists, it is selected automatically. - */ - readonly activeProviderId: IObservable; - - /** - * Set the active sessions provider by ID. - */ - setActiveProvider(providerId: string): void; - /** * Select an existing session as the active session. * Sets `isNewChatSession` context to false and opens the active chat belonging to the session. diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index 5b40930d79efe..bb11f776b1b58 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -450,6 +450,7 @@ import './contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.js'; import './contrib/fileTreeView/browser/fileTreeView.contribution.js'; // view registration disabled; filesystem provider still needed import './contrib/configuration/browser/configuration.contribution.js'; import './contrib/workingSet/browser/workingSet.contribution.js'; +import './contrib/browserView/browser/sessionBrowserView.contribution.js'; import './contrib/editor/browser/editor.contribution.js'; import './contrib/terminal/browser/sessionsTerminalContribution.js'; diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index 351500b090211..24bb3646edbdb 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -215,6 +215,7 @@ import './contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.js'; // Local Agent Host import './contrib/agentHost/browser/localAgentHost.contribution.js'; import './contrib/agentHost/browser/agentSessionSettings.contribution.js'; +import './contrib/agentHost/browser/agentHostSettings.contribution.js'; // Tunnel Host (allow remote connections to local agent host) import './contrib/tunnelHost/electron-browser/tunnelHost.contribution.js'; diff --git a/src/vs/sessions/sessions.web.main.ts b/src/vs/sessions/sessions.web.main.ts index 6abba66db2c46..9fac1f377960c 100644 --- a/src/vs/sessions/sessions.web.main.ts +++ b/src/vs/sessions/sessions.web.main.ts @@ -156,6 +156,7 @@ import './contrib/remoteAgentHost/browser/remoteAgentHostTerminal.contribution.j import './contrib/remoteAgentHost/browser/remoteAgentHost.contribution.js'; import './contrib/remoteAgentHost/browser/remoteAgentHostActions.js'; import './contrib/agentHost/browser/agentSessionSettings.contribution.js'; +import './contrib/agentHost/browser/agentHostSettings.contribution.js'; // Host filter dropdown in the titlebar (scopes the sessions list to a host) import './contrib/remoteAgentHost/browser/hostFilter.contribution.js'; diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index db1ab19404444..7f7211ae2af4b 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -714,10 +714,10 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA } async $registerChatSessionCustomizationProvider(handle: number, chatSessionType: string, metadata: IChatSessionCustomizationProviderMetadataDto, extensionId: ExtensionIdentifier): Promise { - // In the sessions window, only the Copilot CLI harness is accepted via the - // extension API. Other harnesses (e.g. Claude) are not shown in sessions. + // In the sessions window, only accept harnesses for session types that + // have a registered content provider (i.e., can actually run sessions). // AHP remote servers register directly via registerExternalHarness. - if (this._environmentService.isSessionsWindow && chatSessionType !== 'copilotcli') { + if (this._environmentService.isSessionsWindow && !this._chatSessionService.getContentProviderSchemes().includes(chatSessionType)) { return; } diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 38d3b8669c654..7d0cd091eae0c 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -32,6 +32,7 @@ import { IExtHostRpcService } from './extHostRpcService.js'; import * as typeConvert from './extHostTypeConverters.js'; import { Diagnostic } from './extHostTypeConverters.js'; import * as extHostTypes from './extHostTypes.js'; +import { isEqual } from '../../../base/common/resources.js'; type ChatSessionTiming = vscode.ChatSessionItem['timing']; @@ -44,6 +45,9 @@ class ChatSessionInputStateImpl implements vscode.ChatSessionInputState { readonly #onDidChangeEmitter = new Emitter(); readonly onDidChange = this.#onDidChangeEmitter.event; + readonly #onDidDisposeEmitter = new Emitter(); + readonly onDidDispose = this.#onDidDisposeEmitter.event; + #sessionResource: vscode.Uri | undefined; get sessionResource(): vscode.Uri | undefined { return this.#sessionResource; @@ -81,6 +85,12 @@ class ChatSessionInputStateImpl implements vscode.ChatSessionInputState { _setGroups(groups: readonly vscode.ChatSessionProviderOptionGroup[]): void { this.#groups = groups; } + + _dispose(): void { + this.#onDidDisposeEmitter.fire(); + this.#onDidDisposeEmitter.dispose(); + this.#onDidChangeEmitter.dispose(); + } } // #endregion @@ -579,6 +589,10 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }, dispose: () => { isDisposed = true; + for (const inputState of inputStates) { + inputState._dispose(); + } + inputStates.clear(); disposables.dispose(); }, }); @@ -652,6 +666,11 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio ); if (inputState instanceof ChatSessionInputStateImpl) { + // Dispose any previous input states for this session resource + if (controllerData) { + this._disposeInputStatesForResource(controllerData.inputStates, sessionResource); + } + if (isUntitledChatSession(sessionResource)) { inputState.untitledSessionResource = sessionResource; } else { @@ -805,15 +824,22 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } async $disposeChatSessionContent(providerHandle: number, sessionResource: UriComponents): Promise { - const entry = this._extHostChatSessions.get(URI.revive(sessionResource)); + const resource = URI.revive(sessionResource); + const entry = this._extHostChatSessions.get(resource); if (!entry) { this._logService.warn(`No chat session found for resource: ${sessionResource}`); return; } + // Dispose input states associated with this session + const controllerData = this.getChatSessionItemController(resource.scheme); + if (controllerData) { + this._disposeInputStatesForResource(controllerData.inputStates, resource); + } + entry.disposeCts.cancel(); entry.sessionObj.sessionDisposables.dispose(); - this._extHostChatSessions.delete(URI.revive(sessionResource)); + this._extHostChatSessions.delete(resource); } async $invokeChatSessionRequestHandler(handle: number, sessionResource: UriComponents, request: IChatAgentRequest, history: any[], token: CancellationToken): Promise { @@ -882,6 +908,16 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio return undefined; } + private _disposeInputStatesForResource(inputStates: Set, resource: URI): void { + for (const inputState of inputStates) { + const inputResource = inputState.sessionResource ?? inputState.untitledSessionResource; + if (inputResource && isEqual(resource, inputResource)) { + inputState._dispose(); + inputStates.delete(inputState); + } + } + } + private _createInputStateFromOptions( groups: readonly vscode.ChatSessionProviderOptionGroup[], sessionOptions?: ReadonlyArray<{ optionId: string; value: string }>, @@ -924,6 +960,11 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio ); if (result) { if (result instanceof ChatSessionInputStateImpl) { + // Dispose any previous input states for this session resource + if (sessionResource && controllerData) { + this._disposeInputStatesForResource(controllerData.inputStates, sessionResource); + } + if (sessionResource && isUntitledChatSession(sessionResource)) { result.untitledSessionResource = sessionResource; } else if (sessionResource) { @@ -1193,6 +1234,9 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } if (inputState instanceof ChatSessionInputStateImpl && sessionResource) { + // Dispose any previous input states for this session resource + this._disposeInputStatesForResource(controllerData.inputStates, sessionResource); + if (isUntitledChatSession(sessionResource)) { inputState.untitledSessionResource = sessionResource; } else { diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts index 6b198c848b138..cf9b6123a2de3 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts @@ -68,7 +68,8 @@ export const enum AccessibilityVerbositySettingId { Walkthrough = 'accessibility.verbosity.walkthrough', SourceControl = 'accessibility.verbosity.sourceControl', Find = 'accessibility.verbosity.find', - SessionsChat = 'accessibility.verbosity.sessionsChat' + SessionsChat = 'accessibility.verbosity.sessionsChat', + ChatQuestionCarousel = 'accessibility.verbosity.chatQuestionCarousel' } const baseVerbosityProperty: IConfigurationPropertySchema = { @@ -210,6 +211,10 @@ const configuration: IConfigurationNode = { description: localize('verbosity.sessionsChat', 'Provide information about how to access the Agents app accessibility help menu when the chat input is focused.'), ...baseVerbosityProperty }, + [AccessibilityVerbositySettingId.ChatQuestionCarousel]: { + description: localize('verbosity.chatQuestionCarousel', 'Provide information about how to navigate and interact with the chat question carousel, including how to focus the terminal when applicable.'), + ...baseVerbosityProperty + }, 'accessibility.signalOptions.volume': { 'description': localize('accessibility.signalOptions.volume', "The volume of the sounds in percent (0-100)."), 'type': 'number', diff --git a/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts b/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts index 77cc549c182a7..d20b820a2ee9a 100644 --- a/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts +++ b/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts @@ -22,6 +22,7 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; import { LRUCachedFunction } from '../../../../base/common/cache.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; const LOADING_SPINNER_SVG = (color: string | undefined) => ` @@ -44,6 +45,14 @@ export interface IBrowserEditorInputData extends IBrowserEditorViewState { readonly id: string; } +/** + * Fired before a {@link BrowserEditorInput} is disposed. Listeners may call + * {@link veto} to prevent disposal and keep the input and its model alive. + */ +export interface IBeforeDisposeBrowserEditorEvent { + veto(): void; +} + export class BrowserEditorInput extends EditorInput { static readonly ID = 'workbench.editorinputs.browser'; static readonly EDITOR_ID = 'workbench.editor.browser'; @@ -56,6 +65,9 @@ export class BrowserEditorInput extends EditorInput { private _modelPromise: Promise | undefined; private _modelStore = this._register(new DisposableStore()); + private readonly _onBeforeDispose = this._register(new Emitter()); + readonly onBeforeDispose: Event = this._onBeforeDispose.event; + constructor( options: IBrowserEditorInputData, private _resolveModel: () => Promise, @@ -88,7 +100,7 @@ export class BrowserEditorInput extends EditorInput { // Auto-close editor when webcontents closes this._modelStore.add(this._model.onDidClose(() => { - this.dispose(); + this.dispose(true); })); // Listen for label-relevant changes to fire onDidChangeLabel @@ -286,7 +298,15 @@ export class BrowserEditorInput extends EditorInput { }; } - override dispose(): void { + override dispose(force?: boolean): void { + if (!force) { + let vetoed = false; + this._onBeforeDispose.fire({ veto: () => { vetoed = true; } }); + if (vetoed) { + return; + } + } + super.dispose(); // Emit `onWillDispose` event first, then clean up the model. if (this._model) { // `toUntyped()` is called after disposal. Store the latest data in `_initialData` so we can still get them there. diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 4b7789a3dee78..88a2eb5da0a23 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -539,17 +539,20 @@ export class BrowserEditor extends EditorPane { this._inputDisposables.clear(); - // Set initial navigation state from the input so that the UI is populated while the model is loading. - this.updateNavigationState({ - url: input.url || '', - title: input.title || '', - canGoBack: false, - canGoForward: false, - certificateError: undefined - }); + let model = input.model; + if (!model) { + // Set initial navigation state from the input so that the UI is populated while the model is loading. + this.updateNavigationState({ + url: input.url || '', + title: input.title || '', + canGoBack: false, + canGoForward: false, + certificateError: undefined + }); - // Resolve the browser view model from the input - const model = await input.resolve(); + // Resolve the browser view model from the input + model = await input.resolve(); + } if (token.isCancellationRequested || this.input !== input) { return; @@ -1007,13 +1010,19 @@ export class BrowserEditor extends EditorPane { * Recompute the layout of the browser container and update the model with the new bounds. * This should generally only be called via layout() to ensure that the container is ready and all necessary styles are loaded. */ - layoutBrowserContainer(): void { + layoutBrowserContainer(retries = 2): void { if (this._model) { this.checkOverlays(); const containerRect = this._browserContainer.getBoundingClientRect(); const cornerRadius = this.window.getComputedStyle(this._browserContainer).borderTopLeftRadius ?? '0'; + // This can happen under certain conditions. Keep trying for a couple of frames to allow things to stabilize. + if ((containerRect.width === 0 || containerRect.height === 0) && retries > 0) { + this.window.requestAnimationFrame(() => this.layoutBrowserContainer(retries - 1)); + return; + } + void this._model.layout({ windowId: this.group.windowId, x: containerRect.left, diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts index a5ba5dd40dd9a..3b40e69a1273f 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts @@ -83,18 +83,18 @@ class BrowserTabQuickPick extends Disposable { super(); this._quickPick = this._register(quickInputService.createQuickPick({ useSeparators: true })); - this._quickPick.placeholder = localize('browser.quickOpenPlaceholder', "Select a browser tab or enter a URL"); + this._quickPick.placeholder = localize('browser.quickOpenPlaceholder', "Select a browser tab"); this._quickPick.matchOnDescription = true; this._quickPick.sortByLabel = false; this._quickPick.buttons = [closeAllButtonItem]; this._register(this._quickPick.onDidTriggerItemButton(async ({ item }) => { - item.editor?.dispose(); + item.editor?.dispose(true); })); this._register(this._quickPick.onDidTriggerButton(async () => { for (const editor of this._browserViewService.getKnownBrowserViews().values()) { - editor.dispose(); + editor.dispose(true); } })); @@ -225,7 +225,6 @@ class BrowserTabQuickPick extends Disposable { class QuickOpenBrowserAction extends Action2 { constructor() { - const neverShowInTitleBar = ContextKeyExpr.equals('config.workbench.browser.showInTitleBar', false); super({ id: BrowserViewCommandId.QuickOpen, title: localize2('browser.quickOpenAction', "Quick Open Browser Tab..."), @@ -239,12 +238,6 @@ class QuickOpenBrowserAction extends Action2 { primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyA, when: BROWSER_EDITOR_ACTIVE }, - menu: { - id: MenuId.TitleBar, - group: 'navigation', - order: 10, - when: ContextKeyExpr.and(CONTEXT_BROWSER_EDITOR_OPEN, neverShowInTitleBar.negate()), - } }); } @@ -274,16 +267,6 @@ class OpenIntegratedBrowserAction extends Action2 { category: BrowserActionCategory, icon: Codicon.globe, f1: true, - menu: { - id: MenuId.TitleBar, - group: 'navigation', - order: 10, - when: ContextKeyExpr.and( - // This is a hack to work around `true` just testing for truthiness of the key. It works since `1 == true` in JS. - ContextKeyExpr.equals('config.workbench.browser.showInTitleBar', 1), - CONTEXT_BROWSER_EDITOR_OPEN.negate() - ) - } }); } @@ -420,18 +403,30 @@ class CloseAllBrowserTabsInGroupAction extends Action2 { } } -class OpenBrowserFromViewMenuAction extends Action2 { - static readonly ID = 'workbench.action.browser.openFromViewMenu'; - +class OpenOrListBrowsersAction extends Action2 { constructor() { super({ - id: OpenBrowserFromViewMenuAction.ID, - title: localize2('browser.openFromViewMenuAction', "Browser"), + id: BrowserViewCommandId.OpenOrList, + title: localize2('browser.openOrListAction', "Browser"), + icon: Codicon.globe, f1: false, keybinding: { weight: KeybindingWeight.WorkbenchContrib, primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.Slash, }, + menu: { + id: MenuId.TitleBar, + group: 'navigation', + order: 10, + when: ContextKeyExpr.and( + ContextKeyExpr.equals('config.workbench.browser.showInTitleBar', false).negate(), + ContextKeyExpr.or( + CONTEXT_BROWSER_EDITOR_OPEN, + // This is a hack to work around `true` just testing for truthiness of the key. It works since `1 == true` in JS. + ContextKeyExpr.equals('config.workbench.browser.showInTitleBar', 1) + ) + ), + } }); } @@ -454,7 +449,7 @@ class OpenBrowserFromViewMenuAction extends Action2 { MenuRegistry.appendMenuItem(MenuId.MenubarViewMenu, { group: '4_auxbar', command: { - id: OpenBrowserFromViewMenuAction.ID, + id: BrowserViewCommandId.OpenOrList, title: localize({ key: 'miOpenBrowser', comment: ['&& denotes a mnemonic'] }, "&&Browser") }, order: 2 @@ -465,7 +460,7 @@ MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: BrowserV registerAction2(QuickOpenBrowserAction); registerAction2(OpenIntegratedBrowserAction); -registerAction2(OpenBrowserFromViewMenuAction); +registerAction2(OpenOrListBrowsersAction); registerAction2(NewTabAction); registerAction2(CloseAllBrowserTabsAction); registerAction2(CloseAllBrowserTabsInGroupAction); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index f7d4917de6895..27d6be945a2c8 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -1013,6 +1013,30 @@ export function registerChatActions() { } }); + registerAction2(class FocusQuestionCarouselTerminalAction extends Action2 { + static readonly ID = 'workbench.action.chat.focusQuestionCarouselTerminal'; + + constructor() { + super({ + id: FocusQuestionCarouselTerminalAction.ID, + title: localize2('interactiveSession.focusQuestionCarouselTerminal.label', "Chat: Focus Terminal from Question Carousel"), + category: CHAT_CATEGORY, + f1: true, + precondition: ContextKeyExpr.and(ChatContextKeys.inChatSession, ChatContextKeys.Editing.hasQuestionCarousel, ChatContextKeys.chatQuestionCarouselHasTerminal), + keybinding: [{ + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.Alt | KeyCode.KeyT, + when: ContextKeyExpr.and(ChatContextKeys.inChatQuestionCarousel, ChatContextKeys.Editing.hasQuestionCarousel, ChatContextKeys.chatQuestionCarouselHasTerminal), + }] + }); + } + + run(accessor: ServicesAccessor): void { + const widgetService = accessor.get(IChatWidgetService); + widgetService.lastFocusedWidget?.focusQuestionCarouselTerminal(); + } + }); + registerAction2(class FocusTipAction extends Action2 { static readonly ID = 'workbench.action.chat.focusTip'; diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts index b6799d5a71523..babb7cc2495f5 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts @@ -12,13 +12,14 @@ import { dirname, isEqual, isEqualOrParent, joinPath } from '../../../../base/co import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; +import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; import { IFileService } from '../../../../platform/files/common/files.js'; 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 { IProgressService, ProgressLocation } from '../../../../platform/progress/common/progress.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js'; import type { Dto } from '../../../services/extensions/common/proxyIdentifier.js'; import { IAgentPluginRepositoryService, IEnsureRepositoryOptions, IPullRepositoryOptions } from '../common/plugins/agentPluginRepositoryService.js'; import { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceReferenceKind, MarketplaceType, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js'; @@ -47,7 +48,7 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi constructor( @ICommandService private readonly _commandService: ICommandService, - @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, + @IEnvironmentService environmentService: IEnvironmentService, @IFileService private readonly _fileService: IFileService, @IInstantiationService instantiationService: IInstantiationService, @ILogService private readonly _logService: ILogService, @@ -55,11 +56,12 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi @IPluginGitService private readonly _pluginGit: IPluginGitService, @IProgressService private readonly _progressService: IProgressService, @IStorageService private readonly _storageService: IStorageService, + @IUserDataProfileService userDataProfileService: IUserDataProfileService, ) { // On native, use the well-known ~/{dataFolderName}/agent-plugins/ path // so that external tools can discover it. On web, fall back to the // internal cache location. - this.agentPluginsHome = environmentService.agentPluginsHome; + this.agentPluginsHome = userDataProfileService.currentProfile.agentPluginsHome; const legacyCacheRoot = joinPath(environmentService.cacheHome, 'agentPlugins'); const oldCacheRoot = environmentService.cacheHome.scheme === 'file' ? legacyCacheRoot diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts index 8970d3be651dd..cb8e2d5fad5f1 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts @@ -161,7 +161,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS }); return this._makeItem(rawId, { title: s.summary, - status: s.status, + status, workingDirectory: s.workingDirectory, createdAt: s.startTime, modifiedAt: s.modifiedTime, @@ -201,6 +201,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS description: this._description, iconPath: getAgentHostIcon(this._productService), status: mapSessionStatus(opts.status), + archived: opts.status !== undefined && (opts.status & SessionStatus.IsArchived) === SessionStatus.IsArchived, metadata: this._buildMetadata(opts.workingDirectory), timing: { created: opts.createdAt, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts index 93a5537200ac9..265496d8c1a66 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts @@ -10,7 +10,7 @@ import { Registry } from '../../../../../../platform/registry/common/platform.js import { IAgentConnection, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, AgentHostIpcLoggingSettingId } from '../../../../../../platform/agentHost/common/agentService.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { StateComponents, type ComponentToState, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; -import type { ActionEnvelope, INotification, SessionAction, TerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import type { ActionEnvelope, INotification, RootAction, SessionAction, TerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { Extensions, IOutputChannel, IOutputChannelRegistry, IOutputService } from '../../../../../services/output/common/output.js'; @@ -216,7 +216,7 @@ export class LoggingAgentConnection extends Disposable implements IAgentConnecti return this._inner.getSubscriptionUnmanaged(kind, resource); } - dispatch(action: SessionAction | TerminalAction): void { + dispatch(action: RootAction | SessionAction | TerminalAction): void { this._log('>>', 'dispatch', action); this._inner.dispatch(action); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts index d9d3931bdb489..5803f37fb5999 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts @@ -12,9 +12,9 @@ import { ICommandService } from '../../../../../platform/commands/common/command import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IQuickInputButton, IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; import { ISessionOpenOptions, openSession } from './agentSessionsOpener.js'; -import { AgentSessionStatus, IAgentSession, isLocalAgentSessionItem } from './agentSessionsModel.js'; +import { IAgentSession, isLocalAgentSessionItem } from './agentSessionsModel.js'; import { IAgentSessionsService } from './agentSessionsService.js'; -import { AgentSessionsSorter, groupAgentSessionsByDate, sessionDateFromNow } from './agentSessionsViewer.js'; +import { AgentSessionsSorter, groupAgentSessionsByDate, type IAgentSessionsFilter, sessionDateFromNow } from './agentSessionsViewer.js'; import { AGENT_SESSION_DELETE_ACTION_ID, AGENT_SESSION_RENAME_ACTION_ID } from './agentSessions.js'; import { AgentSessionsFilter } from './agentSessionsFilter.js'; @@ -62,6 +62,10 @@ export function getSessionButtons(session: IAgentSession): IQuickInputButton[] { return buttons; } +export function shouldShowSessionInPicker(session: IAgentSession, filter: IAgentSessionsFilter): boolean { + return !session.isArchived() && !filter.exclude(session); +} + export interface IAgentSessionsPickerOptions { overrideSessionOpen?(session: IAgentSession, openOptions?: ISessionOpenOptions): Promise; } @@ -141,7 +145,7 @@ export class AgentSessionsPicker { private createPickerItems(filter: AgentSessionsFilter): (ISessionPickItem | IQuickPickSeparator)[] { const sessions = this.agentSessionsService.model.sessions - .filter(session => session.status !== AgentSessionStatus.Completed && !filter.exclude(session)) + .filter(session => shouldShowSessionInPicker(session, filter)) .sort(this.sorter.compare.bind(this.sorter)); const items: (ISessionPickItem | IQuickPickSeparator)[] = []; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsQuickAccess.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsQuickAccess.ts index 7ab8f018143af..125cc9e6c49f9 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsQuickAccess.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsQuickAccess.ts @@ -15,7 +15,7 @@ import { IAgentSession } from './agentSessionsModel.js'; import { openSession } from './agentSessionsOpener.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { AGENT_SESSION_DELETE_ACTION_ID, AGENT_SESSION_RENAME_ACTION_ID } from './agentSessions.js'; -import { archiveButton, deleteButton, getSessionButtons, getSessionDescription, renameButton, unarchiveButton } from './agentSessionsPicker.js'; +import { archiveButton, deleteButton, getSessionButtons, getSessionDescription, renameButton, shouldShowSessionInPicker, unarchiveButton } from './agentSessionsPicker.js'; import { AgentSessionsFilter } from './agentSessionsFilter.js'; export const AGENT_SESSIONS_QUICK_ACCESS_PREFIX = 'agent '; @@ -44,7 +44,7 @@ export class AgentSessionsQuickAccessProvider extends PickerQuickAccessProvider< const picks: Array = []; const sessions = this.agentSessionsService.model.sessions - .filter(session => !this.filter.exclude(session)) + .filter(session => shouldShowSessionInPicker(session, this.filter)) .sort(this.sorter.compare.bind(this.sorter)); const groupedSessions = groupAgentSessionsByDate(sessions); diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index f5b3e5a934eae..be4e6a5217f4f 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -423,6 +423,11 @@ export interface IChatWidget { * @returns Whether the operation succeeded (i.e., a next question exists). */ navigateToNextQuestion(): boolean; + /** + * Focuses the terminal associated with the active question carousel. + * @returns Whether the operation succeeded (i.e., a terminal was found and focused). + */ + focusQuestionCarouselTerminal(): boolean; /** * Toggles focus between the tip widget and the chat input. * Returns false if no tip is visible. diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index 305eda8bcc98a..5035fa261c697 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -32,8 +32,10 @@ import { IHoverService } from '../../../../../../platform/hover/browser/hover.js import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; +import { AccessibilityVerbositySettingId } from '../../../../accessibility/browser/accessibilityConfiguration.js'; import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { RunInTerminalTool } from '../../../../terminal/terminalContribChatExports.js'; import './media/chatQuestionCarousel.css'; @@ -87,6 +89,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent */ private readonly _interactiveUIStore: MutableDisposable = this._register(new MutableDisposable()); private readonly _inChatQuestionCarouselContextKey: IContextKey; + private readonly _chatQuestionCarouselHasTerminalContextKey: IContextKey; private _validationMessageElement: HTMLElement | undefined; private _currentValidationError: string | undefined; private _focusTerminalButtonContainer: HTMLElement | undefined; @@ -101,16 +104,24 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent @IContextKeyService private readonly _contextKeyService: IContextKeyService, @IKeybindingService private readonly _keybindingService: IKeybindingService, @ICommandService private readonly _commandService: ICommandService, + @IConfigurationService private readonly _configurationService: IConfigurationService, ) { super(); this.domNode = dom.$('.chat-question-carousel-container'); this.domNode.id = generateUuid(); this._inChatQuestionCarouselContextKey = ChatContextKeys.inChatQuestionCarousel.bindTo(this._contextKeyService); + this._chatQuestionCarouselHasTerminalContextKey = ChatContextKeys.chatQuestionCarouselHasTerminal.bindTo(this._contextKeyService); const focusTracker = this._register(dom.trackFocus(this.domNode)); - this._register(focusTracker.onDidFocus(() => this._inChatQuestionCarouselContextKey.set(true))); - this._register(focusTracker.onDidBlur(() => this._inChatQuestionCarouselContextKey.set(false))); - this._register({ dispose: () => this._inChatQuestionCarouselContextKey.reset() }); + this._register(focusTracker.onDidFocus(() => { + this._inChatQuestionCarouselContextKey.set(true); + this._chatQuestionCarouselHasTerminalContextKey.set(!!this.carousel.terminalId); + })); + this._register(focusTracker.onDidBlur(() => { + this._inChatQuestionCarouselContextKey.set(false); + this._chatQuestionCarouselHasTerminalContextKey.reset(); + })); + this._register({ dispose: () => { this._inChatQuestionCarouselContextKey.reset(); this._chatQuestionCarouselHasTerminalContextKey.reset(); } }); // Set up accessibility attributes for the carousel container this.domNode.tabIndex = 0; @@ -183,10 +194,14 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent if (carousel.terminalId) { this._focusTerminalButtonContainer = dom.$('.chat-question-focus-terminal-container'); const focusTerminalTitle = localize('chat.questionCarousel.focusTerminalTitle', 'Focus Terminal'); + const kbLabel = this._keybindingService.lookupKeybinding('workbench.action.chat.focusQuestionCarouselTerminal')?.getLabel(); + const focusTerminalAriaLabel = kbLabel + ? localize('chat.questionCarousel.focusTerminalAriaLabel', 'Focus Terminal ({0})', kbLabel) + : focusTerminalTitle; const focusTerminalButton = interactiveStore.add(new Button(this._focusTerminalButtonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); focusTerminalButton.label = `$(${Codicon.terminal.id})`; focusTerminalButton.element.classList.add('chat-question-focus-terminal'); - focusTerminalButton.element.setAttribute('aria-label', focusTerminalTitle); + focusTerminalButton.element.setAttribute('aria-label', focusTerminalAriaLabel); interactiveStore.add(this._hoverService.setupDelayedHover(focusTerminalButton.element, { content: focusTerminalTitle })); interactiveStore.add(focusTerminalButton.onDidClick(() => this._focusTerminal())); @@ -606,11 +621,24 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const messageContent = this.getQuestionText(questionText); const questionCount = this.carousel.questions.length; + let label: string; if (questionCount === 1) { - this.domNode.setAttribute('aria-label', localize('chat.questionCarousel.singleQuestionLabel', 'Chat question: {0}', messageContent)); + label = localize('chat.questionCarousel.singleQuestionLabel', 'Chat question: {0}', messageContent); } else { - this.domNode.setAttribute('aria-label', localize('chat.questionCarousel.multiQuestionLabel', 'Chat question {0} of {1}: {2}', this._currentIndex + 1, questionCount, messageContent)); + label = localize('chat.questionCarousel.multiQuestionLabel', 'Chat question {0} of {1}: {2}', this._currentIndex + 1, questionCount, messageContent); + } + + const verbose = this._configurationService.getValue(AccessibilityVerbositySettingId.ChatQuestionCarousel); + if (verbose && this.carousel.terminalId) { + const kbLabel = this._keybindingService.lookupKeybinding('workbench.action.chat.focusQuestionCarouselTerminal')?.getLabel(); + if (kbLabel) { + label = localize('chat.questionCarousel.combinedFocusTerminalHint', "{0} Use {1} to focus the terminal.", label, kbLabel); + } else { + label = localize('chat.questionCarousel.combinedFocusTerminalHintNoKb', "{0} Use the Focus Terminal from Question Carousel command to focus the terminal.", label); + } } + + this.domNode.setAttribute('aria-label', label); } /** @@ -645,6 +673,14 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent return true; } + public focusTerminal(): boolean { + if (!this.carousel.terminalId) { + return false; + } + this._focusTerminal(); + return true; + } + private renderCurrentQuestion(focusContainerForScreenReader: boolean = false): void { if (!this._questionContainer) { return; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 494623317332d..e649818fb389a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -873,6 +873,10 @@ export class ChatWidget extends Disposable implements IChatWidget { return this.input.navigateToNextQuestion(); } + focusQuestionCarouselTerminal(): boolean { + return this.input.focusQuestionCarouselTerminal(); + } + toggleTipFocus(): boolean { if (this._gettingStartedTipPartRef?.hasFocus()) { this.focusInput(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 68153748c4027..56b30bea3d506 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -2993,6 +2993,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return carousel?.navigateToNextQuestion() ?? false; } + focusQuestionCarouselTerminal(): boolean { + const carousel = this.questionCarousel; + return carousel?.focusTerminal() ?? false; + } + // --- Plan Review --- renderPlanReview(review: IChatPlanReview, context: IChatContentPartRenderContext, options: IChatPlanReviewPartOptions): ChatPlanReviewPart { diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 2f520ecd64f8c..cc37dc7ede093 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -44,6 +44,7 @@ export namespace ChatContextKeys { export const inChatInput = new RawContextKey('inChatInput', false, { type: 'boolean', description: localize('inInteractiveInput', "True when focus is in the chat input, false otherwise.") }); export const inChatSession = new RawContextKey('inChat', false, { type: 'boolean', description: localize('inChat', "True when focus is in the chat widget, false otherwise.") }); export const inChatQuestionCarousel = new RawContextKey('inChatQuestionCarousel', false, { type: 'boolean', description: localize('inChatQuestionCarousel', "True when focus is in the chat question carousel.") }); + export const chatQuestionCarouselHasTerminal = new RawContextKey('chatQuestionCarouselHasTerminal', false, { type: 'boolean', description: localize('chatQuestionCarouselHasTerminal', "True when the chat question carousel was triggered by a terminal and has a terminal to focus.") }); export const inChatEditor = new RawContextKey('inChatEditor', false, { type: 'boolean', description: localize('inChatEditor', "Whether focus is in a chat editor.") }); export const inChatTodoList = new RawContextKey('inChatTodoList', false, { type: 'boolean', description: localize('inChatTodoList', "True when focus is in the chat todo list.") }); export const inChatTip = new RawContextKey('inChatTip', false, { type: 'boolean', description: localize('inChatTip', "True when focus is in a chat tip.") }); diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index e4c806a417cdf..cc8ed426f8524 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -906,6 +906,17 @@ export class LanguageModelsService implements ILanguageModelsService { try { const models = await provider.provideLanguageModelChatInfo({ group: group.name, silent, configuration }, CancellationToken.None); if (models.length) { + // Provide a sensible default for `metadata.detail` so that + // multiple instances of the same vendor (e.g. multiple + // Ollama servers) are distinguishable in the model picker. + // Providers that supply their own `detail` keep it; when + // the provider does not set one, fall back to the user- + // configured group name. + for (let i = 0; i < models.length; i++) { + if (!models[i].metadata.detail) { + models[i] = { ...models[i], metadata: { ...models[i].metadata, detail: group.name } }; + } + } allModels.push(...models); languageModelsGroups.push({ group, modelIdentifiers: models.map(m => m.identifier) }); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 54d983b14c0aa..bb2d7dd5d67a4 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -402,6 +402,10 @@ export class ComputeAutomaticInstructions { // Also filter out the troubleshoot skill when agent debug log file logging setting is disabled const isFileLoggingEnabled = this._configurationService.getValue(AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING); const modelInvocableSkills = agentSkills?.filter(skill => { + if (!skill.description) { + debugInfo.debugDetails.push({ category: 'skipped', name: skill.name, uri: skill.uri, reason: localize('debugDetail.skillNoDescription', 'no description for model invocation') }); + return false; + } if (skill.disableModelInvocation) { debugInfo.debugDetails.push({ category: 'skipped', name: skill.name, uri: skill.uri, reason: localize('debugDetail.skillNotModelInvocable', 'model invocation disabled') }); return false; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts index 0fa39870f848b..a5c6d9392b860 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts @@ -7,6 +7,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { basename, dirname } from '../../../../../../base/common/resources.js'; import { PromptFileSource, PromptsType } from '../promptTypes.js'; import { PromptsStorage } from '../service/promptsService.js'; +import { compareIgnoreCase } from '../../../../../../base/common/strings.js'; /** * File extension for the reusable prompt files. @@ -33,6 +34,13 @@ export const AGENT_FILE_EXTENSION = '.agent.md'; */ export const SKILL_FILENAME = 'SKILL.md'; +/** + * Check if a filename is a skill file (case insensitive). + */ +export function isSkillFilename(filename: string): boolean { + return compareIgnoreCase(filename, SKILL_FILENAME) === 0; +} + /** * Regex for valid skill names: lowercase alphanumeric and hyphens only. */ @@ -250,7 +258,7 @@ export function getPromptFileType(fileUri: URI): PromptsType | undefined { return PromptsType.agent; } - if (filename.toLowerCase() === SKILL_FILENAME.toLowerCase()) { + if (isSkillFilename(filename)) { return PromptsType.skill; } @@ -343,9 +351,9 @@ export function getCleanPromptName(fileUri: URI): string { return basename(fileUri, '.md'); } - // For SKILL.md files (case insensitive), return 'SKILL' - if (fileName.toLowerCase() === SKILL_FILENAME.toLowerCase()) { - return basename(fileUri, '.md'); + // For SKILL.md files (case insensitive), return the parent folder name + if (isSkillFilename(fileName)) { + return getSkillFolderName(fileUri); } // For .md files in .github/agents/ folder, treat them as agent files diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts index e34daa4869c1c..d92b57bbf8d46 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -17,7 +17,7 @@ import { ISequenceValue, IHeaderAttribute, IScalarValue, parseCommaSeparatedList import { IFileService } from '../../../../../../platform/files/common/files.js'; import { IPromptsService } from '../service/promptsService.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; -import { AGENTS_SOURCE_FOLDER, CLAUDE_AGENTS_SOURCE_FOLDER, isInClaudeRulesFolder, LEGACY_MODE_FILE_EXTENSION, VALID_SKILL_NAME_REGEX } from '../config/promptFileLocations.js'; +import { AGENTS_SOURCE_FOLDER, CLAUDE_AGENTS_SOURCE_FOLDER, isInClaudeRulesFolder, isSkillFilename, LEGACY_MODE_FILE_EXTENSION, VALID_SKILL_NAME_REGEX } from '../config/promptFileLocations.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { dirname } from '../../../../../../base/common/resources.js'; @@ -60,31 +60,18 @@ export class PromptValidator { } private async validateSkillAttributes(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise { - if (promptType !== PromptsType.skill) { + if (promptType !== PromptsType.skill || !promptAST.header) { return; } - const nameAttribute = promptAST.header?.attributes.find(attr => attr.key === PromptHeaderAttributes.name); + const nameAttribute = promptAST.header.getAttribute(PromptHeaderAttributes.name); if (!nameAttribute) { report(toMarker( - localize('promptValidator.skillNameMissing', "Skill must provide a name."), + localize('promptValidator.skillNameMissing', "Skill should provide a name."), new Range(1, 1, 1, 4), - MarkerSeverity.Error + MarkerSeverity.Warning )); - return; - } - - const descriptionAttribute = promptAST.header?.attributes.find(attr => attr.key === PromptHeaderAttributes.description); - if (!descriptionAttribute) { - report(toMarker( - localize('promptValidator.skillDescriptionMissing', "Skill must provide a description."), - new Range(1, 1, 1, 4), - MarkerSeverity.Error - )); - return; - } - - if (nameAttribute.value.type === 'scalar') { + } else if (nameAttribute.value.type === 'scalar') { const skillName = nameAttribute.value.value.trim(); if (skillName.length > 0) { if (!VALID_SKILL_NAME_REGEX.test(skillName)) { @@ -97,7 +84,7 @@ export class PromptValidator { // Extract folder name from path (e.g., .github/skills/my-skill/SKILL.md -> my-skill) const pathParts = promptAST.uri.path.split('/'); - const skillIndex = pathParts.findIndex(part => part === 'SKILL.md'); + const skillIndex = pathParts.findIndex(part => isSkillFilename(part)); if (skillIndex > 0) { const folderName = pathParts[skillIndex - 1]; if (folderName && skillName !== folderName) { @@ -110,6 +97,41 @@ export class PromptValidator { } } } + + const descriptionAttribute = promptAST.header.getAttribute(PromptHeaderAttributes.description); + if (!descriptionAttribute) { + report(toMarker( + localize('promptValidator.skillDescriptionMissing', "Skill should provide a description."), + new Range(1, 1, 1, 4), + MarkerSeverity.Warning + )); + + // Without a description, user-invocable: false is invalid because the skill + // would be model-only but has no description for the model to decide when to use it. + if (promptAST.header.userInvocable === false) { + const userInvocableAttr = promptAST.header.getAttribute(PromptHeaderAttributes.userInvocable); + if (userInvocableAttr) { + report(toMarker( + localize('promptValidator.skillUserInvocableRequiresDescription', "A description is required when user-invocable is false, because the model needs a description to decide when to load the skill."), + userInvocableAttr.value.range, + MarkerSeverity.Error + )); + } + } + + // Without a description, disable-model-invocation: false (model invocation enabled) + // is the default but if explicitly set, report an error that a description is needed. + if (promptAST.header.disableModelInvocation === false) { + const disableModelInvocationAttr = promptAST.header.getAttribute(PromptHeaderAttributes.disableModelInvocation); + if (disableModelInvocationAttr) { + report(toMarker( + localize('promptValidator.skillModelInvocationRequiresDescription', "A description is required when model invocation is enabled, because the model needs a description to decide when to load the skill."), + disableModelInvocationAttr.value.range, + MarkerSeverity.Error + )); + } + } + } } private async validateBody(promptAST: ParsedPromptFile, target: Target, report: (markers: IMarkerData) => void): Promise { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 74de7de710220..45391f5e64b1b 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -50,37 +50,6 @@ import { getCanonicalPluginCommandId, IAgentPlugin, IAgentPluginService } from ' import { isContributionEnabled } from '../../enablement.js'; import { assertNever } from '../../../../../../base/common/assert.js'; -/** - * Error thrown when a skill file is missing the required name attribute. - */ -export class SkillMissingNameError extends Error { - constructor(public readonly uri: URI) { - super('Skill file must have a name attribute'); - } -} - -/** - * Error thrown when a skill file is missing the required description attribute. - */ -export class SkillMissingDescriptionError extends Error { - constructor(public readonly uri: URI) { - super('Skill file must have a description attribute'); - } -} - -/** - * Error thrown when a skill's name does not match its parent folder name. - */ -export class SkillNameMismatchError extends Error { - constructor( - public readonly uri: URI, - public readonly skillName: string, - public readonly folderName: string - ) { - super(`Skill name must match folder name: expected "${folderName}" but got "${skillName}"`); - } -} - type PromptFileProviderEntry = { extension: IExtensionDescription; type: PromptsType; @@ -635,7 +604,14 @@ export class PromptsService extends Disposable implements IPromptsService { const parseResults = await Promise.all(slashCommandFiles.map(async promptPath => { try { const parsedPromptFile = await this.parseNew(promptPath.uri, token); - const rawName = parsedPromptFile?.header?.name ?? promptPath.name ?? getCleanPromptName(promptPath.uri); + let rawName: string; + if (promptPath.type === PromptsType.skill) { + // For skills, always use the folder name as the canonical name + // (consistent with computeSkillDiscoveryInfo) + rawName = getSkillFolderName(promptPath.uri); + } else { + rawName = parsedPromptFile?.header?.name ?? promptPath.name ?? getCleanPromptName(promptPath.uri); + } // For plugin resources, ensure the canonical plugin prefix is always preserved even when the // file's frontmatter overrides the name. const name = promptPath.source === PromptFileSource.Plugin && promptPath.pluginUri @@ -1074,30 +1050,26 @@ export class PromptsService extends Disposable implements IPromptsService { */ private async validateAndSanitizeSkillFile(uri: URI, token: CancellationToken): Promise<{ name: string; description: string | undefined }> { const parsedFile = await this.parseNew(uri, token); - const name = parsedFile.header?.name; + const folderName = getSkillFolderName(uri); + let name = parsedFile.header?.name; if (!name) { - this.logger.error(`[validateAndSanitizeSkillFile] Agent skill file missing name attribute: ${uri}`); - throw new SkillMissingNameError(uri); + this.logger.debug(`[validateAndSanitizeSkillFile] Agent skill file missing name attribute, using folder name "${folderName}": ${uri}`); + name = folderName; } const description = parsedFile.header?.description; - if (!description) { - this.logger.error(`[validateAndSanitizeSkillFile] Agent skill file missing description attribute: ${uri}`); - throw new SkillMissingDescriptionError(uri); - } // Sanitize the name first (remove XML tags and truncate) - const sanitizedName = this.truncateAgentSkillName(name, uri); + let sanitizedName = this.truncateAgentSkillName(name, uri); - // Validate that the sanitized name matches the parent folder name (per agentskills.io specification) - const folderName = getSkillFolderName(uri); + // If sanitized name doesn't match folder name, use folder name (consistent with computeSkillDiscoveryInfo) if (sanitizedName !== folderName) { - this.logger.error(`[validateAndSanitizeSkillFile] Agent skill name "${sanitizedName}" does not match folder name "${folderName}": ${uri}`); - throw new SkillNameMismatchError(uri, sanitizedName, folderName); + this.logger.debug(`[validateAndSanitizeSkillFile] Agent skill name "${sanitizedName}" does not match folder name "${folderName}", using folder name: ${uri}`); + sanitizedName = folderName; } - const sanitizedDescription = this.truncateAgentSkillDescription(parsedFile.header?.description, uri); + const sanitizedDescription = description ? this.truncateAgentSkillDescription(description, uri) : undefined; return { name: sanitizedName, description: sanitizedDescription }; } 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 e13bdbb7cb532..8d9e3d78cb8b5 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 @@ -16,7 +16,7 @@ import { timeout } from '../../../../../../base/common/async.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; -import { isSessionAction, type ActionEnvelope, type INotification, type SessionAction, type TerminalAction, type IToolCallConfirmedAction, type ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { isSessionAction, type ActionEnvelope, type INotification, type RootAction, type SessionAction, type TerminalAction, type IToolCallConfirmedAction, type ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import type { CustomizationRef } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, createSessionState, createActiveTurn, ROOT_STATE_URI, PolicyState, ResponsePartKind, StateComponents, buildSubagentSessionUri, ToolResultContentType, type SessionState, type SessionSummary, RootState, type ToolCallState, type AgentInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; @@ -117,7 +117,7 @@ class MockAgentHostService extends mock() { // Protocol methods public override readonly clientId = 'test-window-1'; - public dispatchedActions: { action: SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = []; + public dispatchedActions: { action: RootAction | SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = []; /** Returns dispatched actions filtered to turn-related types only * (excludes lifecycle actions like activeClientChanged). */ @@ -157,7 +157,7 @@ class MockAgentHostService extends mock() { }; } unsubscribe(_resource: URI): void { } - dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void { + dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void { this.dispatchedActions.push({ action, clientId, clientSeq }); } private _nextSeq = 1; @@ -250,7 +250,7 @@ class MockAgentHostService extends mock() { onDidApplyAction: Event.None, } satisfies IAgentSubscription; } - override dispatch(action: SessionAction | TerminalAction): void { + override dispatch(action: RootAction | SessionAction | TerminalAction): void { this.dispatchedActions.push({ action, clientId: this.clientId, clientSeq: this._nextSeq++ }); // Apply state-management actions optimistically so state-dependent // logic (e.g. customization re-dispatch) sees the correct activeClient. @@ -639,6 +639,23 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(listController.items.length, 0); }); + + test('refresh marks archived sessions as archived items', async () => { + const { listController, agentHostService } = createContribution(disposables); + + agentHostService.addSession({ + session: AgentSession.uri('copilot', 'archived'), + startTime: 1000, + modifiedTime: 2000, + summary: 'Archived session', + isArchived: true, + }); + + await listController.refresh(CancellationToken.None); + + assert.strictEqual(listController.items.length, 1); + assert.strictEqual(listController.items[0].archived, true); + }); }); // ---- Session ID resolution in _invokeAgent -------------------------- diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts index 58add97bfc0d4..4bedaad86a720 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -14,7 +14,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; -import { isSessionAction, type ActionEnvelope, type INotification, type SessionAction, type TerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { isSessionAction, type ActionEnvelope, type INotification, type RootAction, type SessionAction, type TerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionLifecycle, SessionStatus, createSessionState, StateComponents, type SessionState, type SessionSummary, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { sessionReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { ToolResultContentType } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -283,9 +283,9 @@ suite('AgentHostClientTools', () => { override readonly onAgentHostStart = Event.None; private readonly _liveSubscriptions = new Map }>(); - public dispatchedActions: (SessionAction | TerminalAction)[] = []; + public dispatchedActions: (RootAction | SessionAction | TerminalAction)[] = []; - override dispatch(action: SessionAction | TerminalAction): void { + override dispatch(action: RootAction | SessionAction | TerminalAction): void { this.dispatchedActions.push(action); if (isSessionAction(action) && action.type === 'session/activeClientChanged') { const entry = this._liveSubscriptions.get(action.session); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index 16ca999555b4d..ceb3d70c97b6d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -13,6 +13,7 @@ import { ITreeSorter } from '../../../../../../base/browser/ui/tree/tree.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Event } from '../../../../../../base/common/event.js'; import { AgentSessionsGrouping, AgentSessionsSorting } from '../../../browser/agentSessions/agentSessionsFilter.js'; +import { shouldShowSessionInPicker } from '../../../browser/agentSessions/agentSessionsPicker.js'; suite('sessionDateFromNow', () => { @@ -1348,6 +1349,62 @@ suite('AgentSessionsSorter', () => { }); }); +suite('AgentSessionsPicker', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function createSession(overrides: Partial<{ + id: string; + status: ChatSessionStatus; + isArchived: boolean; + }>): IAgentSession { + return { + providerType: 'test', + providerLabel: 'Test', + resource: URI.parse(`test://session/${overrides.id ?? 'default'}`), + status: overrides.status ?? ChatSessionStatus.Completed, + label: `Session ${overrides.id ?? 'default'}`, + icon: Codicon.terminal, + timing: { + created: Date.now(), + lastRequestStarted: undefined, + lastRequestEnded: undefined, + }, + changes: undefined, + metadata: undefined, + isArchived: () => overrides.isArchived ?? false, + setArchived: () => { }, + isPinned: () => false, + setPinned: () => { }, + isRead: () => true, + isMarkedUnread: () => false, + setRead: () => { }, + }; + } + + const filter: IAgentSessionsFilter = { + onDidChange: Event.None, + exclude: () => false, + getExcludes: () => ({ providers: [], states: [], archived: true, read: false, repositoryGroupCapped: true }), + isDefault: () => true, + limitResults: () => undefined, + notifyResults: () => { }, + reset: () => { }, + sortResults: () => undefined, + }; + + test('keeps completed sessions but excludes archived sessions', () => { + const completed = createSession({ id: 'completed', status: ChatSessionStatus.Completed }); + const inProgress = createSession({ id: 'in-progress', status: ChatSessionStatus.InProgress }); + const archived = createSession({ id: 'archived', status: ChatSessionStatus.Completed, isArchived: true }); + + assert.deepStrictEqual( + [completed, inProgress, archived].filter(session => shouldShowSessionInPicker(session, filter)).map(session => session.label), + ['Session completed', 'Session in-progress'] + ); + }); +}); + suite('groupAgentSessionsByDate with sortBy', () => { ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts index f69380c85c138..3479662495e69 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts @@ -14,6 +14,7 @@ import { ILogService, NullLogService } from '../../../../../../platform/log/comm import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; +import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js'; import { AgentPluginRepositoryService } from '../../../browser/agentPluginRepositoryService.js'; import { IMarketplacePlugin, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; import { IPluginGitService } from '../../../common/plugins/pluginGitService.js'; @@ -75,7 +76,8 @@ suite('AgentPluginRepositoryService', () => { return undefined; }, } as unknown as ICommandService); - instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache'), agentPluginsHome: URI.file('/cache/agentPlugins') } as unknown as IEnvironmentService); + instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as unknown as IEnvironmentService); + instantiationService.stub(IUserDataProfileService, { currentProfile: { agentPluginsHome: URI.file('/cache/agentPlugins') } } as unknown as IUserDataProfileService); instantiationService.stub(IFileService, fileService); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(INotificationService, { notify: () => undefined } as unknown as INotificationService); @@ -155,7 +157,8 @@ suite('AgentPluginRepositoryService', () => { repoExists = true; }, })); - instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache'), agentPluginsHome: URI.file('/cache/agentPlugins') } as unknown as IEnvironmentService); + instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as unknown as IEnvironmentService); + instantiationService.stub(IUserDataProfileService, { currentProfile: { agentPluginsHome: URI.file('/cache/agentPlugins') } } as unknown as IUserDataProfileService); instantiationService.stub(IFileService, fileService); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(INotificationService, { notify: () => undefined } as unknown as INotificationService); @@ -196,7 +199,8 @@ suite('AgentPluginRepositoryService', () => { const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(ICommandService, { executeCommand: async () => undefined } as unknown as ICommandService); instantiationService.stub(IPluginGitService, stubPluginGit()); - instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache'), agentPluginsHome: URI.file('/cache/agentPlugins') } as unknown as IEnvironmentService); + instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as unknown as IEnvironmentService); + instantiationService.stub(IUserDataProfileService, { currentProfile: { agentPluginsHome: URI.file('/cache/agentPlugins') } } as unknown as IUserDataProfileService); instantiationService.stub(IFileService, { exists: async () => true } as unknown as IFileService); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(INotificationService, { notify: () => undefined } as unknown as INotificationService); @@ -294,7 +298,8 @@ suite('AgentPluginRepositoryService', () => { const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(ICommandService, { executeCommand: async () => undefined } as unknown as ICommandService); instantiationService.stub(IPluginGitService, stubPluginGit()); - instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache'), agentPluginsHome: URI.file('/cache/agentPlugins') } as unknown as IEnvironmentService); + instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as unknown as IEnvironmentService); + instantiationService.stub(IUserDataProfileService, { currentProfile: { agentPluginsHome: URI.file('/cache/agentPlugins') } } as unknown as IUserDataProfileService); instantiationService.stub(IFileService, { exists: async () => true, del: async (resource: URI) => { onDel(resource); }, @@ -388,7 +393,8 @@ suite('AgentPluginRepositoryService', () => { const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(ICommandService, { executeCommand: async () => undefined } as unknown as ICommandService); instantiationService.stub(IPluginGitService, stubPluginGit()); - instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache'), agentPluginsHome: URI.file('/cache/agentPlugins') } as unknown as IEnvironmentService); + instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as unknown as IEnvironmentService); + instantiationService.stub(IUserDataProfileService, { currentProfile: { agentPluginsHome: URI.file('/cache/agentPlugins') } } as unknown as IUserDataProfileService); instantiationService.stub(IFileService, { exists: async () => true, del: async () => { throw new Error('permission denied'); }, diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts index d561511eae027..f0f3f26b8e7fa 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts @@ -2094,7 +2094,7 @@ suite('PromptValidator', () => { assert.strictEqual(markers[0].message, `The skill name 'different-name' should match the folder name 'my-skill'.`); }); - test('skill without name attribute should error', async () => { + test('skill without name attribute should warn', async () => { const content = [ '---', 'description: Test Skill', @@ -2103,8 +2103,14 @@ suite('PromptValidator', () => { ].join('\n'); const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); assert.strictEqual(markers.length, 1); - assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `Skill must provide a name.`); + assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); + assert.strictEqual(markers[0].message, 'Skill should provide a name.'); + }); + + test('skill without frontmatter should not warn about missing name or description', async () => { + const content = 'This is a skill without any frontmatter.'; + const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); + assert.deepStrictEqual(markers, []); }); test('skill with empty name should error', async () => { @@ -2121,7 +2127,7 @@ suite('PromptValidator', () => { assert.strictEqual(markers[0].message, `The 'name' attribute must not be empty.`); }); - test('skill without description attribute should error', async () => { + test('skill without description attribute should warn', async () => { const content = [ '---', 'name: my-skill', @@ -2130,8 +2136,40 @@ suite('PromptValidator', () => { ].join('\n'); const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); assert.strictEqual(markers.length, 1); - assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `Skill must provide a description.`); + assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); + assert.strictEqual(markers[0].message, 'Skill should provide a description.'); + }); + + test('skill without description but with user-invocable false should error on that attribute', async () => { + const content = [ + '---', + 'name: my-skill', + 'user-invocable: false', + '---', + 'This is a skill.' + ].join('\n'); + const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); + assert.strictEqual(markers.length, 2); + assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); + assert.strictEqual(markers[0].message, 'Skill should provide a description.'); + assert.strictEqual(markers[1].severity, MarkerSeverity.Error); + assert.ok(markers[1].message.includes('description is required when user-invocable is false')); + }); + + test('skill without description but with disable-model-invocation false should error on that attribute', async () => { + const content = [ + '---', + 'name: my-skill', + 'disable-model-invocation: false', + '---', + 'This is a skill.' + ].join('\n'); + const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); + assert.strictEqual(markers.length, 2); + assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); + assert.strictEqual(markers[0].message, 'Skill should provide a description.'); + assert.strictEqual(markers[1].severity, MarkerSeverity.Error); + assert.ok(markers[1].message.includes('description is required when model invocation is enabled')); }); test('skill with empty description should error', async () => { diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts index 6908d48f9d4d0..c2594ec58aa02 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts @@ -1143,3 +1143,220 @@ suite('LanguageModels - Per-Model Configuration', function () { assert.deepStrictEqual(receivedOptions, { configuration: { temperature: 0.2 } }); }); }); + +suite('LanguageModels - Provider Group Detail Fallback', function () { + + const disposables = new DisposableStore(); + + teardown(function () { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('model.detail falls back to the group name so multiple instances of the same vendor are distinguishable', async function () { + const languageModelsService = disposables.add(new LanguageModelsService( + new class extends mock() { + override activateByEvent() { + return Promise.resolve(); + } + }, + new NullLogService(), + disposables.add(new TestStorageService()), + new MockContextKeyService(), + new class extends mock() { + override onDidChangeLanguageModelGroups = Event.None; + override getLanguageModelsProviderGroups() { + return [ + { vendor: 'multi-vendor', name: 'Local' }, + { vendor: 'multi-vendor', name: 'Remote' } + ]; + } + }, + new class extends mock() { }, + new TestSecretStorageService(), + new class extends mock() { override readonly version = '1.100.0'; }, + new class extends mock() { }, + )); + + languageModelsService.deltaLanguageModelChatProviderDescriptors([ + // Cast needed: TypeFromJsonSchema resolves the `anyOf`+`$ref` configuration + // field to `undefined`, but the runtime value must be truthy so the + // service treats this vendor as a configurable (BYOK) provider and + // resolves models for every group rather than stopping after the first. + { vendor: 'multi-vendor', displayName: 'Multi Vendor', configuration: {} as unknown as undefined, managementCommand: undefined, when: undefined } + ], []); + + disposables.add(languageModelsService.registerLanguageModelProvider('multi-vendor', { + onDidChange: Event.None, + provideLanguageModelChatInfo: async (options) => { + if (!options.group) { + return []; + } + // Provider returns the same model id for each group, but the + // identifier is namespaced by group so they don't collide. + // The provider does not set `detail`; the service should fall + // back to the per-instance group name. + return [{ + metadata: { + extension: nullExtensionDescription.identifier, + name: 'Shared Model', + vendor: 'multi-vendor', + family: 'shared', + version: '1.0', + id: 'shared-model', + maxInputTokens: 100, + maxOutputTokens: 100, + modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY, + isDefaultForLocation: {} + } satisfies ILanguageModelChatMetadata, + identifier: `multi-vendor/${options.group}/shared-model` + }]; + }, + sendChatRequest: async () => { throw new Error(); }, + provideTokenCount: async () => { throw new Error(); } + })); + + await languageModelsService.selectLanguageModels({}); + + const local = languageModelsService.lookupLanguageModel('multi-vendor/Local/shared-model'); + const remote = languageModelsService.lookupLanguageModel('multi-vendor/Remote/shared-model'); + + assert.deepStrictEqual( + { localDetail: local?.detail, remoteDetail: remote?.detail }, + { localDetail: 'Local', remoteDetail: 'Remote' } + ); + }); + + test('model.detail falls back to the group name even when there is only a single group for the vendor', async function () { + const languageModelsService = disposables.add(new LanguageModelsService( + new class extends mock() { + override activateByEvent() { + return Promise.resolve(); + } + }, + new NullLogService(), + disposables.add(new TestStorageService()), + new MockContextKeyService(), + new class extends mock() { + override onDidChangeLanguageModelGroups = Event.None; + override getLanguageModelsProviderGroups() { + return [ + { vendor: 'single-vendor', name: 'Only Instance' } + ]; + } + }, + new class extends mock() { }, + new TestSecretStorageService(), + new class extends mock() { override readonly version = '1.100.0'; }, + new class extends mock() { }, + )); + + languageModelsService.deltaLanguageModelChatProviderDescriptors([ + { vendor: 'single-vendor', displayName: 'Single Vendor', configuration: undefined, managementCommand: undefined, when: undefined } + ], []); + + disposables.add(languageModelsService.registerLanguageModelProvider('single-vendor', { + onDidChange: Event.None, + provideLanguageModelChatInfo: async (options) => { + if (!options.group) { + return []; + } + return [{ + metadata: { + extension: nullExtensionDescription.identifier, + name: 'Solo Model', + vendor: 'single-vendor', + family: 'solo', + version: '1.0', + id: 'solo-model', + maxInputTokens: 100, + maxOutputTokens: 100, + modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY, + isDefaultForLocation: {} + } satisfies ILanguageModelChatMetadata, + identifier: `single-vendor/${options.group}/solo-model` + }]; + }, + sendChatRequest: async () => { throw new Error(); }, + provideTokenCount: async () => { throw new Error(); } + })); + + await languageModelsService.selectLanguageModels({}); + + const solo = languageModelsService.lookupLanguageModel('single-vendor/Only Instance/solo-model'); + + assert.strictEqual(solo?.detail, 'Only Instance'); + }); + + test('a provider-supplied detail is preserved when multiple groups exist', async function () { + const languageModelsService = disposables.add(new LanguageModelsService( + new class extends mock() { + override activateByEvent() { + return Promise.resolve(); + } + }, + new NullLogService(), + disposables.add(new TestStorageService()), + new MockContextKeyService(), + new class extends mock() { + override onDidChangeLanguageModelGroups = Event.None; + override getLanguageModelsProviderGroups() { + return [ + { vendor: 'detail-vendor', name: 'Local' }, + { vendor: 'detail-vendor', name: 'Remote' } + ]; + } + }, + new class extends mock() { }, + new TestSecretStorageService(), + new class extends mock() { override readonly version = '1.100.0'; }, + new class extends mock() { }, + )); + + languageModelsService.deltaLanguageModelChatProviderDescriptors([ + // Cast needed: see equivalent comment in the multi-vendor test above. + { vendor: 'detail-vendor', displayName: 'Detail Vendor', configuration: {} as unknown as undefined, managementCommand: undefined, when: undefined } + ], []); + + disposables.add(languageModelsService.registerLanguageModelProvider('detail-vendor', { + onDidChange: Event.None, + provideLanguageModelChatInfo: async (options) => { + if (!options.group) { + return []; + } + // Provider supplies its own detail. The service should leave + // it untouched and only fall back to the group name when the + // provider does not set one. + return [{ + metadata: { + extension: nullExtensionDescription.identifier, + name: 'Detailed Model', + vendor: 'detail-vendor', + family: 'detailed', + version: '1.0', + id: 'detailed-model', + detail: `Detailed (${options.group})`, + maxInputTokens: 100, + maxOutputTokens: 100, + modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY, + isDefaultForLocation: {} + } satisfies ILanguageModelChatMetadata, + identifier: `detail-vendor/${options.group}/detailed-model` + }]; + }, + sendChatRequest: async () => { throw new Error(); }, + provideTokenCount: async () => { throw new Error(); } + })); + + await languageModelsService.selectLanguageModels({}); + + const local = languageModelsService.lookupLanguageModel('detail-vendor/Local/detailed-model'); + const remote = languageModelsService.lookupLanguageModel('detail-vendor/Remote/detailed-model'); + + assert.deepStrictEqual( + { localDetail: local?.detail, remoteDetail: remote?.detail }, + { localDetail: 'Detailed (Local)', remoteDetail: 'Detailed (Remote)' } + ); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts index ddc024803a1cd..a20da4cabfeb6 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts @@ -1888,22 +1888,17 @@ suite('ComputeAutomaticInstructions', () => { assert.equal(skillsList.length, 1, 'There should be one skills list'); const skills = xmlContents(skillsList[0], 'skill'); - assert.equal(skills.length, 3, 'All three skills should be included despite missing/mismatched metadata'); + assert.equal(skills.length, 2, 'Skills with description should be included; skill without description is excluded from model invocation'); // Skill with missing name should use folder name as fallback assert.equal(xmlContents(skills[0], 'name')[0], 'no-name-skill'); assert.equal(xmlContents(skills[0], 'description')[0], 'A skill without a name'); assert.equal(xmlContents(skills[0], 'file')[0], getFilePath(`${rootFolder}/.claude/skills/no-name-skill/SKILL.md`)); - // Skill with missing description should still be listed - assert.equal(xmlContents(skills[1], 'name')[0], 'no-desc-skill'); - assert.equal(xmlContents(skills[1], 'description').length, 0, 'Should have no description element'); - assert.equal(xmlContents(skills[1], 'file')[0], getFilePath(`${rootFolder}/.claude/skills/no-desc-skill/SKILL.md`)); - // Skill with mismatched name should use folder name - assert.equal(xmlContents(skills[2], 'name')[0], 'actual-folder'); - assert.equal(xmlContents(skills[2], 'description')[0], 'A skill with mismatched name'); - assert.equal(xmlContents(skills[2], 'file')[0], getFilePath(`${rootFolder}/.claude/skills/actual-folder/SKILL.md`)); + assert.equal(xmlContents(skills[1], 'name')[0], 'actual-folder'); + assert.equal(xmlContents(skills[1], 'description')[0], 'A skill with mismatched name'); + assert.equal(xmlContents(skills[1], 'file')[0], getFilePath(`${rootFolder}/.claude/skills/actual-folder/SKILL.md`)); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts index f7e22613dee3b..61a066e216826 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; -import { getPromptFileType, getCleanPromptName, isPromptOrInstructionsFile } from '../../../../common/promptSyntax/config/promptFileLocations.js'; +import { getPromptFileType, getCleanPromptName, isPromptOrInstructionsFile, isSkillFilename } from '../../../../common/promptSyntax/config/promptFileLocations.js'; import { PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; suite('promptFileLocations', function () { @@ -190,19 +190,19 @@ suite('promptFileLocations', function () { assert.strictEqual(getCleanPromptName(uri), 'test.txt'); }); - test('removes .md extension for SKILL.md (uppercase)', () => { + test('returns folder name for SKILL.md (uppercase)', () => { const uri = URI.file('/workspace/.github/skills/test/SKILL.md'); - assert.strictEqual(getCleanPromptName(uri), 'SKILL'); + assert.strictEqual(getCleanPromptName(uri), 'test'); }); - test('removes .md extension for skill.md (lowercase)', () => { - const uri = URI.file('/workspace/.github/skills/test/skill.md'); - assert.strictEqual(getCleanPromptName(uri), 'skill'); + test('returns folder name for skill.md (lowercase)', () => { + const uri = URI.file('/workspace/.github/skills/my-skill/skill.md'); + assert.strictEqual(getCleanPromptName(uri), 'my-skill'); }); - test('removes .md extension for Skill.md (mixed case)', () => { - const uri = URI.file('/workspace/.github/skills/test/Skill.md'); - assert.strictEqual(getCleanPromptName(uri), 'Skill'); + test('returns folder name for Skill.md (mixed case)', () => { + const uri = URI.file('/workspace/.github/skills/another-skill/Skill.md'); + assert.strictEqual(getCleanPromptName(uri), 'another-skill'); }); }); @@ -232,4 +232,24 @@ suite('promptFileLocations', function () { assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/settings.json')), true); }); }); + + suite('isSkillFilename', () => { + test('SKILL.md (uppercase) should return true', () => { + assert.strictEqual(isSkillFilename('SKILL.md'), true); + }); + + test('skill.md (lowercase) should return true', () => { + assert.strictEqual(isSkillFilename('skill.md'), true); + }); + + test('Skill.md (mixed case) should return true', () => { + assert.strictEqual(isSkillFilename('Skill.md'), true); + }); + + test('other filenames should return false', () => { + assert.strictEqual(isSkillFilename('README.md'), false); + assert.strictEqual(isSkillFilename('SKILL.txt'), false); + assert.strictEqual(isSkillFilename('my-skill.md'), false); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index fc58f19982daf..83370fe1f3c0c 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -3287,6 +3287,112 @@ suite('PromptsService', () => { assert.strictEqual(resultAfterDispose?.length, 1, 'Should find 1 skill after disposal'); assert.strictEqual(resultAfterDispose?.[0].name, 'Local Skill'); }); + + test('should use folder name for contributed skill with missing name', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'contributed-no-name-test'; + const rootFolder = `/${rootFolderName}`; + workspaceContextService.setWorkspace(testWorkspace(URI.file(rootFolder))); + + const contributedSkillUri = URI.parse('file://extensions/my-extension/my-skill/SKILL.md'); + const extension = { identifier: { value: 'test.my-extension' } } as unknown as IExtensionDescription; + + await mockFiles(fileService, [ + { + path: contributedSkillUri.path, + contents: [ + '---', + 'description: "A skill without a name"', + '---', + 'Skill content', + ], + }, + ]); + + const registered = service.registerContributedFile(PromptsType.skill, contributedSkillUri, extension, undefined, undefined); + + const result = await service.findAgentSkills(CancellationToken.None); + assert.ok(result, 'Should return results'); + + const skill = result.find(s => s.name === 'my-skill'); + assert.ok(skill, 'Should find skill using folder name as fallback'); + assert.strictEqual(skill.description, 'A skill without a name'); + + registered.dispose(); + }); + + test('should accept contributed skill with missing description', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'contributed-no-desc-test'; + const rootFolder = `/${rootFolderName}`; + workspaceContextService.setWorkspace(testWorkspace(URI.file(rootFolder))); + + const contributedSkillUri = URI.parse('file://extensions/my-extension/no-desc-skill/SKILL.md'); + const extension = { identifier: { value: 'test.my-extension' } } as unknown as IExtensionDescription; + + await mockFiles(fileService, [ + { + path: contributedSkillUri.path, + contents: [ + '---', + 'name: "no-desc-skill"', + '---', + 'Skill content without description', + ], + }, + ]); + + const registered = service.registerContributedFile(PromptsType.skill, contributedSkillUri, extension, undefined, undefined); + + const result = await service.findAgentSkills(CancellationToken.None); + assert.ok(result, 'Should return results'); + + const skill = result.find(s => s.name === 'no-desc-skill'); + assert.ok(skill, 'Should find skill even without description'); + assert.strictEqual(skill.description, undefined); + + registered.dispose(); + }); + + test('should override contributed skill name with folder name on mismatch', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'contributed-mismatch-test'; + const rootFolder = `/${rootFolderName}`; + workspaceContextService.setWorkspace(testWorkspace(URI.file(rootFolder))); + + const contributedSkillUri = URI.parse('file://extensions/my-extension/actual-folder/SKILL.md'); + const extension = { identifier: { value: 'test.my-extension' } } as unknown as IExtensionDescription; + + await mockFiles(fileService, [ + { + path: contributedSkillUri.path, + contents: [ + '---', + 'name: "wrong-name"', + 'description: "A skill with mismatched name"', + '---', + 'Skill content', + ], + }, + ]); + + const registered = service.registerContributedFile(PromptsType.skill, contributedSkillUri, extension, undefined, undefined); + + const result = await service.findAgentSkills(CancellationToken.None); + assert.ok(result, 'Should return results'); + + const skill = result.find(s => s.name === 'actual-folder'); + assert.ok(skill, 'Should find skill using folder name instead of mismatched name'); + assert.strictEqual(skill.description, 'A skill with mismatched name'); + + registered.dispose(); + }); }); suite('getPromptSlashCommands - skills', () => { @@ -3641,9 +3747,9 @@ suite('PromptsService', () => { const slashCommands = await service.getPromptSlashCommands(CancellationToken.None); - // Should include skill with fallback name from filename (SKILL without extension) - const fallbackNameCommand = slashCommands.find(cmd => cmd.name === 'SKILL'); - assert.ok(fallbackNameCommand, 'Should find skill with fallback name from filename'); + // Should include skill with fallback name from folder name + const fallbackNameCommand = slashCommands.find(cmd => cmd.name === 'no-name'); + assert.ok(fallbackNameCommand, 'Should find skill with fallback name from folder name'); assert.strictEqual(fallbackNameCommand.description, 'Skill without name'); // Should include valid skill @@ -3651,6 +3757,39 @@ suite('PromptsService', () => { assert.ok(validSkillCommand, 'Should find valid skill'); }); + test('should use folder name as slash command name when frontmatter name differs', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'slash-commands-folder-name-override'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/skills/test/SKILL.md`, + contents: [ + '---', + 'name: "foo"', + 'description: "A skill with mismatched frontmatter name"', + '---', + 'say hiya!', + ], + }, + ]); + + const slashCommands = await service.getPromptSlashCommands(CancellationToken.None); + + const folderNameCommand = slashCommands.find(cmd => cmd.name === 'test'); + assert.ok(folderNameCommand, 'Should find skill using folder name as slash command name'); + assert.strictEqual(folderNameCommand.description, 'A skill with mismatched frontmatter name'); + + const frontmatterNameCommand = slashCommands.find(cmd => cmd.name === 'foo'); + assert.strictEqual(frontmatterNameCommand, undefined, 'Should not find skill using frontmatter name'); + }); + test('should not duplicate slash commands with same name from different types', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); @@ -4089,12 +4228,15 @@ suite('PromptsService', () => { const slashCommands = await service.getPromptSlashCommands(CancellationToken.None); - // Even when SKILL.md has name: "run-ci", it must be prefixed with the plugin name - const skillCommand = slashCommands.find(cmd => cmd.name === 'devtools:run-ci'); - assert.ok(skillCommand, 'Plugin skill frontmatter name should be qualified with plugin prefix'); + // Skill name is derived from folder name (ci), not frontmatter name (run-ci), + // and prefixed with the plugin name + const skillCommand = slashCommands.find(cmd => cmd.name === 'devtools:ci'); + assert.ok(skillCommand, 'Plugin skill folder name should be qualified with plugin prefix'); assert.strictEqual(skillCommand.description, 'Run CI pipeline'); - // The unprefixed name should not appear + // The frontmatter name should not appear + assert.strictEqual(slashCommands.find(cmd => cmd.name === 'devtools:run-ci'), undefined, + 'Frontmatter skill name should not appear as slash command'); assert.strictEqual(slashCommands.find(cmd => cmd.name === 'run-ci'), undefined, 'Unprefixed skill name should not appear as slash command'); diff --git a/src/vs/workbench/contrib/editSessions/test/browser/editSessions.test.ts b/src/vs/workbench/contrib/editSessions/test/browser/editSessions.test.ts index 12be1cfe7d4b4..70ba7c5495cdb 100644 --- a/src/vs/workbench/contrib/editSessions/test/browser/editSessions.test.ts +++ b/src/vs/workbench/contrib/editSessions/test/browser/editSessions.test.ts @@ -166,6 +166,7 @@ suite('Edit session sync', () => { promptsHome: URI.file('promptsHome'), extensionsResource: URI.file('extensionsResource'), cacheHome: URI.file('cacheHome'), + agentPluginsHome: URI.file('agentPluginsHome'), }; }); diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts index 47874f09ade60..35a683edd7f58 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts @@ -5,10 +5,9 @@ import * as DOM from '../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; -import { HoverStyle, type IHoverOptions, type IHoverWidget } from '../../../../base/browser/ui/hover/hover.js'; +import { HoverStyle, type IHoverOptions } from '../../../../base/browser/ui/hover/hover.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { SimpleIconLabel } from '../../../../base/browser/ui/iconLabel/simpleIconLabel.js'; -import { RunOnceScheduler } from '../../../../base/common/async.js'; import { Emitter } from '../../../../base/common/event.js'; import { IMarkdownString, MarkdownString, createMarkdownLink } from '../../../../base/common/htmlContent.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; @@ -110,33 +109,6 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { }, }; - private addHoverDisposables(disposables: DisposableStore, element: HTMLElement, showHover: (focus: boolean) => IHoverWidget | undefined) { - disposables.clear(); - const scheduler: RunOnceScheduler = disposables.add(new RunOnceScheduler(() => { - const hover = showHover(false); - if (hover) { - disposables.add(hover); - } - }, this.configurationService.getValue('workbench.hover.delay'))); - disposables.add(DOM.addDisposableListener(element, DOM.EventType.MOUSE_OVER, () => { - if (!scheduler.isScheduled()) { - scheduler.schedule(); - } - })); - disposables.add(DOM.addDisposableListener(element, DOM.EventType.MOUSE_LEAVE, () => { - scheduler.cancel(); - })); - disposables.add(DOM.addDisposableListener(element, DOM.EventType.KEY_DOWN, (e) => { - const evt = new StandardKeyboardEvent(e); - if (evt.equals(KeyCode.Space) || evt.equals(KeyCode.Enter)) { - const hover = showHover(true); - if (hover) { - disposables.add(hover); - } - e.preventDefault(); - } - })); - } private createWorkspaceTrustIndicator(): SettingIndicator { const disposables = new DisposableStore(); @@ -145,21 +117,17 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { workspaceTrustLabel.text = '$(shield) ' + localize('workspaceUntrustedLabel', "Requires workspace trust"); const content = localize('trustLabel', "The setting value can only be applied in a trusted workspace."); - const showHover = (focus: boolean) => { - return this.hoverService.showInstantHover({ - ...this.defaultHoverOptions, - content, - target: workspaceTrustElement, - actions: [{ - label: localize('manageWorkspaceTrust', "Manage Workspace Trust"), - commandId: 'workbench.trust.manage', - run: (target: HTMLElement) => { - this.commandService.executeCommand('workbench.trust.manage'); - } - }], - }, focus); - }; - this.addHoverDisposables(disposables, workspaceTrustElement, showHover); + disposables.add(this.hoverService.setupDelayedHover(workspaceTrustElement, () => ({ + ...this.defaultHoverOptions, + content, + actions: [{ + label: localize('manageWorkspaceTrust', "Manage Workspace Trust"), + commandId: 'workbench.trust.manage', + run: (target: HTMLElement) => { + this.commandService.executeCommand('workbench.trust.manage'); + } + }], + }), { setupKeyboardEvents: true })); return { element: workspaceTrustElement, label: workspaceTrustLabel, @@ -186,14 +154,10 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { syncIgnoredLabel.text = localize('extensionSyncIgnoredLabel', 'Not synced'); const syncIgnoredHoverContent = localize('syncIgnoredTitle', "This setting is ignored during sync"); - const showHover = (focus: boolean) => { - return this.hoverService.showInstantHover({ - ...this.defaultHoverOptions, - content: syncIgnoredHoverContent, - target: syncIgnoredElement - }, focus); - }; - this.addHoverDisposables(disposables, syncIgnoredElement, showHover); + disposables.add(this.hoverService.setupDelayedHover(syncIgnoredElement, { + ...this.defaultHoverOptions, + content: syncIgnoredHoverContent, + }, { setupKeyboardEvents: true })); return { element: syncIgnoredElement, @@ -233,14 +197,10 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { const advancedLabel = disposables.add(new SimpleIconLabel(advancedIndicator)); advancedLabel.text = localize('advancedLabel', "Advanced"); - const showHover = (focus: boolean) => { - return this.hoverService.showInstantHover({ - ...this.defaultHoverOptions, - content: ADVANCED_INDICATOR_DESCRIPTION, - target: advancedIndicator - }, focus); - }; - this.addHoverDisposables(disposables, advancedIndicator, showHover); + disposables.add(this.hoverService.setupDelayedHover(advancedIndicator, { + ...this.defaultHoverOptions, + content: ADVANCED_INDICATOR_DESCRIPTION, + }, { setupKeyboardEvents: true })); return { element: advancedIndicator, @@ -351,14 +311,10 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { localize('experimentalLabel', "Experimental"); const content = isPreviewSetting ? PREVIEW_INDICATOR_DESCRIPTION : EXPERIMENTAL_INDICATOR_DESCRIPTION; - const showHover = (focus: boolean) => { - return this.hoverService.showInstantHover({ - ...this.defaultHoverOptions, - content, - target: this.previewIndicator.element - }, focus); - }; - this.addHoverDisposables(this.previewIndicator.disposables, this.previewIndicator.element, showHover); + this.previewIndicator.disposables.add(this.hoverService.setupDelayedHover(this.previewIndicator.element, { + ...this.defaultHoverOptions, + content, + }, { setupKeyboardEvents: true })); this.render(); } @@ -402,21 +358,17 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { this.scopeOverridesIndicator.label.text = '$(briefcase) ' + localize('policyLabelText', "Managed by organization"); const content = localize('policyDescription', "This setting is managed by your organization and its actual value cannot be changed."); - const showHover = (focus: boolean) => { - return this.hoverService.showInstantHover({ - ...this.defaultHoverOptions, - content, - actions: [{ - label: localize('policyFilterLink', "View policy settings"), - commandId: '_settings.action.viewPolicySettings', - run: (_) => { - onApplyFilter.fire(`@${POLICY_SETTING_TAG}`); - } - }], - target: this.scopeOverridesIndicator.element - }, focus); - }; - this.addHoverDisposables(this.scopeOverridesIndicator.disposables, this.scopeOverridesIndicator.element, showHover); + this.scopeOverridesIndicator.disposables.add(this.hoverService.setupDelayedHover(this.scopeOverridesIndicator.element, () => ({ + ...this.defaultHoverOptions, + content, + actions: [{ + label: localize('policyFilterLink', "View policy settings"), + commandId: '_settings.action.viewPolicySettings', + run: (_) => { + onApplyFilter.fire(`@${POLICY_SETTING_TAG}`); + } + }], + }), { setupKeyboardEvents: true })); } else if (element.settingsTarget === ConfigurationTarget.USER_LOCAL && this.configurationService.isSettingAppliedForAllProfiles(element.setting.key)) { this.scopeOverridesIndicator.element.style.display = 'inline'; this.scopeOverridesIndicator.element.classList.add('setting-indicator'); @@ -424,14 +376,10 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { this.scopeOverridesIndicator.label.text = localize('applicationSetting', "Applies to all profiles"); const content = localize('applicationSettingDescription', "The setting is not specific to the current profile, and will retain its value when switching profiles."); - const showHover = (focus: boolean) => { - return this.hoverService.showInstantHover({ - ...this.defaultHoverOptions, - content, - target: this.scopeOverridesIndicator.element - }, focus); - }; - this.addHoverDisposables(this.scopeOverridesIndicator.disposables, this.scopeOverridesIndicator.element, showHover); + this.scopeOverridesIndicator.disposables.add(this.hoverService.setupDelayedHover(this.scopeOverridesIndicator.element, { + ...this.defaultHoverOptions, + content, + }, { setupKeyboardEvents: true })); } else if (element.overriddenScopeList.length || element.overriddenDefaultsLanguageList.length) { if (element.overriddenScopeList.length === 1 && !element.overriddenDefaultsLanguageList.length) { // We can inline the override and show all the text in the label @@ -540,17 +488,13 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { defaultOverrideHoverContent = localize('multipledefaultOverriddenDetails', "A default values has been set by {0}", sourceToDisplay.slice(0, -1).join(', ') + ' & ' + sourceToDisplay.slice(-1)); } - const showHover = (focus: boolean) => { - return this.hoverService.showInstantHover({ - content: new MarkdownString().appendMarkdown(defaultOverrideHoverContent), - target: this.defaultOverrideIndicator.element, - style: HoverStyle.Pointer, - position: { - hoverPosition: HoverPosition.BELOW, - }, - }, focus); - }; - this.addHoverDisposables(this.defaultOverrideIndicator.disposables, this.defaultOverrideIndicator.element, showHover); + this.defaultOverrideIndicator.disposables.add(this.hoverService.setupDelayedHover(this.defaultOverrideIndicator.element, () => ({ + content: new MarkdownString().appendMarkdown(defaultOverrideHoverContent), + style: HoverStyle.Pointer, + position: { + hoverPosition: HoverPosition.BELOW, + }, + }), { setupKeyboardEvents: true })); } this.render(); } diff --git a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts index 18370f0de2fe7..134d47026e2b1 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts @@ -12,7 +12,7 @@ import { IAgentConnection, IAgentCreateSessionConfig, IAgentResolveSessionConfig import { ActionType, StateAction } from '../../../../../platform/agentHost/common/state/protocol/actions.js'; import { RootState, TerminalClaimKind, type TerminalState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; -import type { ActionEnvelope, SessionAction, TerminalAction, INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js'; +import type { ActionEnvelope, RootAction, SessionAction, TerminalAction, INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult } from '../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { AgentHostPty } from '../../browser/agentHostPty.js'; @@ -32,7 +32,7 @@ class MockAgentConnection implements IAgentConnection { private readonly _onDidNotification = new Emitter(); readonly onDidNotification: Event = this._onDidNotification.event; - readonly dispatchedActions: (SessionAction | TerminalAction)[] = []; + readonly dispatchedActions: (RootAction | SessionAction | TerminalAction)[] = []; readonly createdTerminals: CreateTerminalParams[] = []; readonly disposedTerminals: URI[] = []; readonly subscribedResources: URI[] = []; @@ -115,7 +115,7 @@ class MockAgentConnection implements IAgentConnection { getSubscriptionUnmanaged(_kind: StateComponents, _resource: URI): IAgentSubscription | undefined { return undefined; } - dispatch(action: SessionAction | TerminalAction): void { + dispatch(action: RootAction | SessionAction | TerminalAction): void { this.dispatchedActions.push(action); } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts index 9b653563d6fdb..ef5f463b2a4b5 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts @@ -181,6 +181,22 @@ export async function trackIdleOnPrompt( state = TerminalState.PromptAfterExecuting; scheduler.schedule(); }, promptFallbackMs ?? 1000)); + // Schedule an initial fallback with a longer timeout so we can detect idle + // even when no terminal data events arrive at all (e.g. shell integration + // is broken and the command finishes silently or hangs waiting for input). + // Without this, if no data events fire, neither scheduler is ever triggered + // and trackIdleOnPrompt blocks forever. We use a longer initial delay (10s) + // to avoid falsely reporting completion for commands that are slow to start + // producing output. Once any data arrives, the onData handler takes over + // with the shorter promptFallbackMs interval. + const initialFallbackScheduler = store.add(new RunOnceScheduler(() => { + if (state === TerminalState.Executing || state === TerminalState.PromptAfterExecuting) { + return; + } + state = TerminalState.PromptAfterExecuting; + scheduler.schedule(); + }, 10_000)); + initialFallbackScheduler.schedule(); // Only schedule when a prompt sequence (A) is seen after an execute sequence (C). This prevents // cases where the command is executed before the prompt is written. While not perfect, sitting // on an A without a C following shortly after is a very good indicator that the command is done @@ -194,6 +210,9 @@ export async function trackIdleOnPrompt( PromptAfterExecuting, } store.add(onData(e => { + // Once any data arrives, cancel the initial fallback — the data-driven + // promptFallbackScheduler handles rescheduling from here. + initialFallbackScheduler.cancel(); // Update state // p10k fires C as `133;C;` const matches = e.matchAll(/(?:\x1b\]|\x9d)[16]33;(?[ACD])(?:;.*)?(?:\x1b\\|\x07|\x9c)/g); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/taskHelpers.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/taskHelpers.ts index fa39d76ec7dc5..5c2827493a09f 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/taskHelpers.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/taskHelpers.ts @@ -104,9 +104,10 @@ export async function getTaskForTool(id: string | undefined, taskDefinition: { t } let tasksForWorkspace; - const workspaceFolderPath = URI.file(workspaceFolder).path; + const getPathForCompare = (uri: URI) => uri.path.replace(/\/$/, '').toLowerCase(); + const workspaceFolderPath = getPathForCompare(URI.file(workspaceFolder)); for (const [folder, tasks] of workspaceFolderToTaskMap) { - if (URI.parse(folder).path === workspaceFolderPath) { + if (getPathForCompare(URI.parse(folder)) === workspaceFolderPath) { tasksForWorkspace = tasks; break; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts index 6b0dccf6065bc..f2de8f4aa509c 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts @@ -4,11 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../../../../base/common/lifecycle.js'; +import { isPowerShell } from '../../runInTerminalHelpers.js'; +import { TreeSitterCommandParser, TreeSitterCommandParserLanguage } from '../../treeSitterCommandParser.js'; import { ITerminalSandboxService, TerminalSandboxPrerequisiteCheck } from '../../../common/terminalSandboxService.js'; import type { ICommandLineRewriter, ICommandLineRewriterOptions, ICommandLineRewriterResult } from './commandLineRewriter.js'; export class CommandLineSandboxRewriter extends Disposable implements ICommandLineRewriter { constructor( + private readonly _treeSitterCommandParser: TreeSitterCommandParser, @ITerminalSandboxService private readonly _sandboxService: ITerminalSandboxService, ) { super(); @@ -20,7 +23,7 @@ export class CommandLineSandboxRewriter extends Disposable implements ICommandLi return undefined; } - const wrappedCommand = this._sandboxService.wrapCommand(options.commandLine, options.requestUnsandboxedExecution, options.shell); + const wrappedCommand = await this._sandboxService.wrapCommand(options.commandLine, options.requestUnsandboxedExecution, options.shell, await this._parseCommandKeywords(options), options.cwd); return { rewritten: wrappedCommand.command, reasoning: wrappedCommand.requiresUnsandboxConfirmation ? 'Switched command to unsandboxed execution because the command includes a domain that is not in the sandbox allowlist' : 'Wrapped command for sandbox execution', @@ -31,4 +34,19 @@ export class CommandLineSandboxRewriter extends Disposable implements ICommandLi deniedDomains: wrappedCommand.deniedDomains, }; } + + private async _parseCommandKeywords(options: ICommandLineRewriterOptions): Promise { + try { + if (options.requestUnsandboxedExecution === true) { + // if the user is requesting unsandboxed execution, not required to parse the command. + return []; + } + const languageId = isPowerShell(options.shell, options.os) + ? TreeSitterCommandParserLanguage.PowerShell + : TreeSitterCommandParserLanguage.Bash; + return await this._treeSitterCommandParser.extractCommandKeywords(languageId, options.commandLine); + } catch { + return []; + } + } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index af9159a59466f..8f4c3c3837768 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -572,7 +572,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { this._register(this._instantiationService.createInstance(CommandLinePwshChainOperatorRewriter, this._treeSitterCommandParser)), ]; if (this._enableCommandLineSandboxRewriting) { - this._commandLineRewriters.push(this._register(this._instantiationService.createInstance(CommandLineSandboxRewriter))); + this._commandLineRewriters.push(this._register(this._instantiationService.createInstance(CommandLineSandboxRewriter, this._treeSitterCommandParser))); } // BackgroundDetachRewriter must come after SandboxRewriter so that nohup/Start-Process // wraps the entire sandbox runtime, keeping both the sandbox and the child process alive diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts index 068717e365246..1e1b8ff4c4a3a 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { timeout } from '../../../../../../base/common/async.js'; -import type { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { appendEscapedMarkdownInlineCode, createCommandUri, isMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; @@ -16,7 +16,7 @@ import { IChatWidgetService } from '../../../../chat/browser/chat.js'; import { IChatService, IChatMultiSelectAnswer, IChatQuestionAnswerValue, IChatQuestionCarousel, IChatSingleSelectAnswer } from '../../../../chat/common/chatService/chatService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../../chat/common/tools/languageModelToolsService.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { ITerminalChatService, ITerminalService } from '../../../../terminal/browser/terminal.js'; +import { ITerminalChatService, ITerminalInstance, ITerminalService } from '../../../../terminal/browser/terminal.js'; import { getOutput } from '../outputHelpers.js'; import { buildCommandDisplayText, isMultilineCommand, normalizeCommandForExecution } from '../runInTerminalHelpers.js'; import { RunInTerminalTool } from './runInTerminalTool.js'; @@ -27,7 +27,7 @@ export const SendToTerminalToolData: IToolData = { id: TerminalToolId.SendToTerminal, toolReferenceName: 'sendToTerminal', displayName: localize('sendToTerminalTool.displayName', 'Send to Terminal'), - modelDescription: `Send input text to an active terminal execution (identified by the \`id\` returned from ${TerminalToolId.RunInTerminal}). The 'command' field may be empty or whitespace to press Enter (useful for interactive prompts). The result includes the last few lines of terminal output captured shortly after sending.`, + modelDescription: `Send input text to an active terminal execution (identified by the \`id\` returned from ${TerminalToolId.RunInTerminal}). The 'command' field may be empty or whitespace to press Enter (useful for interactive prompts). By default, returns the last 20 lines of terminal output captured shortly after sending. Set 'waitForOutput' to true for interactive programs (games, REPLs, etc.) to wait until the terminal becomes idle before returning output — this gives you the program's response to your input.`, icon: Codicon.terminal, source: ToolDataSource.Internal, inputSchema: { @@ -42,6 +42,10 @@ export const SendToTerminalToolData: IToolData = { type: 'string', description: 'The input text to send to the terminal. The text is sent followed by Enter. Provide an empty or whitespace string to send just Enter (for interactive prompts).' }, + waitForOutput: { + type: 'boolean', + description: 'When true, waits for the terminal to become idle (no new output for a short period) before returning, instead of returning immediately. Use this for interactive programs where you need to see the full response to your input. Defaults to false.' + }, }, required: [ 'id', @@ -53,6 +57,7 @@ export const SendToTerminalToolData: IToolData = { export interface ISendToTerminalInputParams { id: string; command: string; + waitForOutput?: boolean; } const FocusTerminalByIdCommandId = 'workbench.action.terminal.chat.focusTerminalById'; @@ -320,7 +325,7 @@ export class SendToTerminalTool extends Disposable implements IToolImpl { return false; } - async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise { const args = invocation.parameters as ISendToTerminalInputParams; if (!args.id) { @@ -342,6 +347,9 @@ export class SendToTerminalTool extends Disposable implements IToolImpl { }; } + // Register a marker before sending so we can scope output to just the response + const startMarker = execution.instance.registerMarker?.(); + if (isMultilineCommand(args.command)) { // Multiline commands (e.g. heredocs) must preserve newlines and use // bracketed paste mode so the shell treats the input as a single paste @@ -353,14 +361,60 @@ export class SendToTerminalTool extends Disposable implements IToolImpl { await execution.instance.sendText(normalizeCommandForExecution(args.command), true); } - await timeout(100); - const recentOutput = getOutput(execution.instance, undefined, { lastNLines: 5 }); + let recentOutput: string; + if (args.waitForOutput) { + // Wait for the terminal to become idle (no new data) before returning. + // This is critical for interactive programs (games, REPLs, etc.) where + // the response arrives asynchronously after the input. + recentOutput = await this._waitForIdleOutput(execution, startMarker, token); + } else { + await timeout(2000, token); + recentOutput = getOutput(execution.instance, startMarker ?? undefined, { lastNLines: 20 }); + } return { content: [{ kind: 'text', - value: `Successfully sent command to terminal ${args.id}.${recentOutput ? `\n\nTerminal output (last 5 lines):\n${recentOutput}` : ''}` + value: `Successfully sent command to terminal ${args.id}.${recentOutput ? `\n\nTerminal output:\n${recentOutput}` : ''}` }] }; } + + /** + * Waits for the terminal to become idle (no new output for a sustained period) + * and returns the output produced since the given marker. + */ + private async _waitForIdleOutput( + execution: ReturnType & {}, + startMarker: ReturnType | undefined, + token: CancellationToken, + ): Promise { + const maxWaitMs = 30_000; // 30 seconds maximum wait + const idleThresholdMs = 2_000; // Consider idle after 2s of no data + const pollIntervalMs = 500; + let waited = 0; + let lastDataTime = Date.now(); + + const cts = new CancellationTokenSource(token); + const dataListener = execution.instance.onData(() => { + lastDataTime = Date.now(); + }); + + try { + while (!cts.token.isCancellationRequested && waited < maxWaitMs) { + await timeout(pollIntervalMs, cts.token); + waited += pollIntervalMs; + + const timeSinceLastData = Date.now() - lastDataTime; + if (timeSinceLastData >= idleThresholdMs) { + break; + } + } + } finally { + dataListener.dispose(); + cts.dispose(); + } + + return getOutput(execution.instance, startMarker ?? undefined); + } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/treeSitterCommandParser.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/treeSitterCommandParser.ts index 6e4d115f68f64..58811e656e80a 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/treeSitterCommandParser.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/treeSitterCommandParser.ts @@ -8,6 +8,7 @@ import { RunOnceScheduler } from '../../../../../base/common/async.js'; import { BugIndicatingError, ErrorNoTelemetry } from '../../../../../base/common/errors.js'; import { Lazy } from '../../../../../base/common/lazy.js'; import { Disposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { posix, win32 } from '../../../../../base/common/path.js'; import { ITreeSitterLibraryService } from '../../../../../editor/common/services/treeSitter/treeSitterLibraryService.js'; import { ICommandFileWriteParser } from './commandParsers/commandFileWriteParser.js'; import { SedFileWriteParser } from './commandParsers/sedFileWriteParser.js'; @@ -72,6 +73,18 @@ export class TreeSitterCommandParser extends Disposable { return captures; } + async extractCommandKeywords(languageId: TreeSitterCommandParserLanguage, commandLine: string): Promise { + const captures = await this._queryTree(languageId, commandLine, '(command_name) @command'); + const keywords = new Set(); + for (const capture of captures) { + const normalized = this._normalizeCommandKeyword(capture.node.text); + if (normalized) { + keywords.add(normalized); + } + } + return [...keywords]; + } + async getFileWrites(languageId: TreeSitterCommandParserLanguage, commandLine: string): Promise { let query: string; switch (languageId) { @@ -124,6 +137,17 @@ export class TreeSitterCommandParser extends Disposable { return query.captures(tree.rootNode); } + private _normalizeCommandKeyword(token: string): string | undefined { + const unquoted = token.replace(/^['"]|['"]$/g, ''); + if (!unquoted) { + return undefined; + } + + const pathBase = unquoted.includes('\\') ? win32.basename(unquoted) : posix.basename(unquoted); + const normalized = pathBase.toLowerCase().replace(/\.(?:exe|cmd|bat|ps1)$/i, ''); + return normalized || undefined; + } + private async _doQuery(languageId: TreeSitterCommandParserLanguage, commandLine: string, querySource: string): Promise<{ tree: Tree; query: Query }> { const language = await this._treeSitterLibraryService.getLanguagePromise(languageId); if (!language) { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index b221aeb42ac09..ea37a983d3b20 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -564,6 +564,12 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary = new Map([ + ['git', TerminalSandboxReadAllowListOperation.Git], + ['gh', TerminalSandboxReadAllowListOperation.Git], + ['node', TerminalSandboxReadAllowListOperation.Node], + ['npm', TerminalSandboxReadAllowListOperation.Node], + ['npx', TerminalSandboxReadAllowListOperation.Node], + ['pnpm', TerminalSandboxReadAllowListOperation.Node], + ['yarn', TerminalSandboxReadAllowListOperation.Node], + ['corepack', TerminalSandboxReadAllowListOperation.Node], + ['bun', TerminalSandboxReadAllowListOperation.Node], + ['deno', TerminalSandboxReadAllowListOperation.Node], + ['nvm', TerminalSandboxReadAllowListOperation.Node], + ['volta', TerminalSandboxReadAllowListOperation.Node], + ['fnm', TerminalSandboxReadAllowListOperation.Node], + ['asdf', TerminalSandboxReadAllowListOperation.Node], + ['mise', TerminalSandboxReadAllowListOperation.Node], + ['cargo', TerminalSandboxReadAllowListOperation.Rust], + ['rustc', TerminalSandboxReadAllowListOperation.Rust], + ['rustup', TerminalSandboxReadAllowListOperation.Rust], + ['go', TerminalSandboxReadAllowListOperation.Go], + ['gofmt', TerminalSandboxReadAllowListOperation.Go], + ['python', TerminalSandboxReadAllowListOperation.Python], + ['python3', TerminalSandboxReadAllowListOperation.Python], + ['pip', TerminalSandboxReadAllowListOperation.Python], + ['pip3', TerminalSandboxReadAllowListOperation.Python], + ['poetry', TerminalSandboxReadAllowListOperation.Python], + ['uv', TerminalSandboxReadAllowListOperation.Python], + ['pipx', TerminalSandboxReadAllowListOperation.Python], + ['pyenv', TerminalSandboxReadAllowListOperation.Python], + ['java', TerminalSandboxReadAllowListOperation.Java], + ['javac', TerminalSandboxReadAllowListOperation.Java], + ['jar', TerminalSandboxReadAllowListOperation.Java], + ['mvn', TerminalSandboxReadAllowListOperation.Java], + ['mvnw', TerminalSandboxReadAllowListOperation.Java], + ['gradle', TerminalSandboxReadAllowListOperation.Java], + ['gradlew', TerminalSandboxReadAllowListOperation.Java], + ['sdk', TerminalSandboxReadAllowListOperation.Java], + ['dotnet', TerminalSandboxReadAllowListOperation.Dotnet], + ['nuget', TerminalSandboxReadAllowListOperation.Nuget], + ['msbuild', TerminalSandboxReadAllowListOperation.Msbuild], + ['ruby', TerminalSandboxReadAllowListOperation.Ruby], + ['gem', TerminalSandboxReadAllowListOperation.Ruby], + ['bundle', TerminalSandboxReadAllowListOperation.Ruby], + ['bundler', TerminalSandboxReadAllowListOperation.Ruby], + ['rake', TerminalSandboxReadAllowListOperation.Ruby], + ['rbenv', TerminalSandboxReadAllowListOperation.Ruby], + ['rvm', TerminalSandboxReadAllowListOperation.Ruby], + ['ccache', TerminalSandboxReadAllowListOperation.NativeBuild], + ['sccache', TerminalSandboxReadAllowListOperation.NativeBuild], + ['cmake', TerminalSandboxReadAllowListOperation.NativeBuild], + ['conan', TerminalSandboxReadAllowListOperation.Conan], +]); + +/** + * Paths that common developer tools typically need to read when the user's home + * directory is broadly denied. This list intentionally avoids obvious credential + * and key material such as ~/.ssh, ~/.gnupg, cloud credentials, package manager + * auth files, and git credential stores. + */ + +function getTerminalSandboxReadAllowListForOperation(operation: TerminalSandboxReadAllowListOperation, os: OperatingSystem): readonly string[] { + switch (operation) { + case TerminalSandboxReadAllowListOperation.Git: + switch (os) { + case OperatingSystem.Macintosh: + case OperatingSystem.Linux: + default: + return [ + '~/.gitconfig', + '~/.config/git/config', + '~/.gitignore', + '~/.gitignore_global', + '~/.config/git/ignore', + '~/.config/git/attributes', + ]; + } + + case TerminalSandboxReadAllowListOperation.Node: + switch (os) { + case OperatingSystem.Macintosh: + return [ + '~/.npm', + '~/Library/Caches/node', + '~/Library/Caches/electron', + '~/Library/Caches/ms-playwright', + '~/Library/Caches/Yarn', + '~/Library/Caches/deno', + '~/Library/pnpm', + '~/.electron-gyp', + '~/.node-gyp', + '~/.yarn/berry', + '~/.local/share/pnpm', + '~/.pnpm-store', + '~/.bun/install/cache', + '~/.bun/bin', + '~/.deno', + '~/.nvm/versions', + '~/.nvm/alias', + '~/.volta/bin', + '~/.volta/tools', + '~/.fnm', + '~/.asdf/installs/nodejs', + '~/.asdf/shims', + '~/.local/share/mise/installs/node', + '~/.local/share/mise/shims', + ]; + case OperatingSystem.Linux: + default: + return [ + '~/.npm', + '~/.cache/node', + '~/.cache/node/corepack', + '~/.cache/electron', + '~/.cache/ms-playwright', + '~/.cache/yarn', + '~/.electron-gyp', + '~/.node-gyp', + '~/.yarn/berry', + '~/.local/share/pnpm', + '~/.pnpm-store', + '~/.bun/install/cache', + '~/.bun/bin', + '~/.deno', + '~/.cache/deno', + '~/.nvm/versions', + '~/.nvm/alias', + '~/.volta/bin', + '~/.volta/tools', + '~/.fnm', + '~/.asdf/installs/nodejs', + '~/.asdf/shims', + '~/.local/share/mise/installs/node', + '~/.local/share/mise/shims', + ]; + } + + case TerminalSandboxReadAllowListOperation.Rust: + switch (os) { + case OperatingSystem.Macintosh: + case OperatingSystem.Linux: + default: + return [ + '~/.cargo/bin', + '~/.cargo/registry', + '~/.cargo/git', + '~/.rustup/toolchains', + ]; + } + + case TerminalSandboxReadAllowListOperation.Go: + switch (os) { + case OperatingSystem.Macintosh: + return [ + '~/go/pkg/mod', + '~/go/bin', + '~/Library/Caches/go-build', + ]; + case OperatingSystem.Linux: + default: + return [ + '~/go/pkg/mod', + '~/go/bin', + '~/.cache/go-build', + ]; + } + + case TerminalSandboxReadAllowListOperation.Python: + switch (os) { + case OperatingSystem.Macintosh: + return [ + '~/Library/Caches/pip', + '~/Library/Caches/pypoetry', + '~/Library/Caches/uv', + '~/.local/bin', + '~/.local/share/virtualenv', + '~/.local/share/pipx', + '~/.pyenv/versions', + '~/.pyenv/shims', + ]; + case OperatingSystem.Linux: + default: + return [ + '~/.cache/pip', + '~/.cache/pypoetry', + '~/.cache/uv', + '~/.local/bin', + '~/.local/share/virtualenv', + '~/.local/share/pipx', + '~/.pyenv/versions', + '~/.pyenv/shims', + ]; + } + + case TerminalSandboxReadAllowListOperation.Java: + switch (os) { + case OperatingSystem.Macintosh: + case OperatingSystem.Linux: + default: + return [ + '~/.m2/repository', + '~/.gradle/caches', + '~/.gradle/wrapper/dists', + '~/.sdkman/candidates', + ]; + } + + case TerminalSandboxReadAllowListOperation.Dotnet: + switch (os) { + case OperatingSystem.Macintosh: + case OperatingSystem.Linux: + default: + return [ + '~/.dotnet', + ]; + } + + case TerminalSandboxReadAllowListOperation.Nuget: + switch (os) { + case OperatingSystem.Macintosh: + return [ + '~/.nuget/packages', + '~/Library/Caches/NuGet/v3-cache', + ]; + case OperatingSystem.Linux: + default: + return [ + '~/.nuget/packages', + '~/.local/share/NuGet/v3-cache', + ]; + } + + case TerminalSandboxReadAllowListOperation.Msbuild: + switch (os) { + case OperatingSystem.Macintosh: + case OperatingSystem.Linux: + default: + return []; + } + + case TerminalSandboxReadAllowListOperation.Ruby: + switch (os) { + case OperatingSystem.Macintosh: + return [ + '~/.gem', + '~/.rbenv/versions', + '~/.rbenv/shims', + '~/.rvm/rubies', + ]; + case OperatingSystem.Linux: + default: + return [ + '~/.gem', + '~/.rbenv/versions', + '~/.rbenv/shims', + '~/.rvm/rubies', + ]; + } + + case TerminalSandboxReadAllowListOperation.NativeBuild: + switch (os) { + case OperatingSystem.Macintosh: + return [ + '~/Library/Caches/ccache', + '~/Library/Caches/sccache', + ]; + case OperatingSystem.Linux: + default: + return [ + '~/.cache/ccache', + '~/.cache/sccache', + ]; + } + + case TerminalSandboxReadAllowListOperation.Conan: + switch (os) { + case OperatingSystem.Macintosh: + case OperatingSystem.Linux: + default: + return [ + '~/.conan2/p', + '~/.conan2/b', + ]; + } + } +} + +export function getTerminalSandboxReadAllowListForCommands(os: OperatingSystem, commandKeywords: readonly string[]): readonly string[] { + if (commandKeywords.length === 0) { + return []; + } + + const operations = new Set(); + for (const keyword of commandKeywords) { + const operation = terminalSandboxReadAllowListKeywordMap.get(keyword.toLowerCase()); + if (operation) { + operations.add(operation); + } + } + + if (operations.size === 0) { + return []; + } + + const paths = [...operations].flatMap(operation => getTerminalSandboxReadAllowListForOperation(operation, os)); + return [...new Set(paths)]; +} diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts index cd02e4f717017..c5a66932ab029 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts @@ -35,6 +35,7 @@ import { ElicitationState, IChatService } from '../../../chat/common/chatService import { SANDBOX_HELPER_CHANNEL_NAME, SandboxHelperChannelClient } from '../../../../../platform/sandbox/common/sandboxHelperIpc.js'; import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../../platform/sandbox/common/settings.js'; import { ITerminalSandboxService, TerminalSandboxPrerequisiteCheck, type ISandboxDependencyInstallOptions, type ISandboxDependencyInstallResult, type ITerminalSandboxPrerequisiteCheckResult, type ITerminalSandboxResolvedNetworkDomains, type ITerminalSandboxWrapResult } from '../../../../../platform/sandbox/common/terminalSandboxService.js'; +import { getTerminalSandboxReadAllowListForCommands } from './terminalSandboxReadAllowList.js'; export { ITerminalSandboxService, TerminalSandboxPrerequisiteCheck } from '../../../../../platform/sandbox/common/terminalSandboxService.js'; export type { ISandboxDependencyInstallOptions, ISandboxDependencyInstallResult, ISandboxDependencyInstallTerminal, ITerminalSandboxPrerequisiteCheckResult, ITerminalSandboxResolvedNetworkDomains, ITerminalSandboxWrapResult } from '../../../../../platform/sandbox/common/terminalSandboxService.js'; @@ -49,6 +50,13 @@ interface ISandboxDependencyInstallTerminalContext { didSendInstallCommand(): boolean; } +interface ITerminalSandboxFileSystemSetting { + denyRead?: string[]; + allowRead?: string[]; + allowWrite?: string[]; + denyWrite?: string[]; +} + export class TerminalSandboxService extends Disposable implements ITerminalSandboxService { readonly _serviceBrand: undefined; private _srtPath: string | undefined; @@ -63,6 +71,8 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb private _remoteEnvDetailsPromise: Promise; private _remoteEnvDetails: IRemoteAgentEnvironment | null = null; private _appRoot: string; + private _commandReadAllowKeywords: readonly string[] = []; + private _commandCwd: URI | undefined; private _os: OperatingSystem = OS; private _defaultWritePaths: string[] = ['~/.npm']; private static readonly _sandboxTempDirName = 'tmp'; @@ -137,7 +147,15 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb return this._os; } - public wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string): ITerminalSandboxWrapResult { + public async wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string, commandKeywords?: readonly string[], cwd?: URI): Promise { + const normalizedCommandKeywords = this._normalizeCommandKeywords(commandKeywords ?? []); + const shouldRefreshConfig = this._commandReadAllowKeywords.length === 0 || this._needsForceUpdateConfigFile || !this._areCommandKeywordsEqual(this._commandReadAllowKeywords, normalizedCommandKeywords) || this._commandCwd?.toString() !== cwd?.toString(); + if (shouldRefreshConfig) { + this._commandReadAllowKeywords = normalizedCommandKeywords; + this._commandCwd = cwd; + await this.getSandboxConfigPath(true); + } + if (!this._sandboxConfigPath || !this._tempDir) { throw new Error('Sandbox config path or temp dir not initialized'); } @@ -173,7 +191,11 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb // Use ELECTRON_RUN_AS_NODE=1 to make Electron executable behave as Node.js // TMPDIR must be set as environment variable before the command // Quote shell arguments so the wrapped command cannot break out of the outer shell. - const wrappedCommand = `PATH="$PATH:${dirname(this._rgPath)}" TMPDIR="${this._tempDir.path}" CLAUDE_TMPDIR="${this._tempDir.path}" "${this._execPath}" "${this._srtPath}" --settings "${this._sandboxConfigPath}" -c ${this._quoteShellArgument(command)}`; + const commandToRunInSandbox = this._getSandboxCommandWithPreservedCwd(command, cwd); + const sandboxRuntimeCommand = `PATH="$PATH:${dirname(this._rgPath)}" TMPDIR="${this._tempDir.path}" CLAUDE_TMPDIR="${this._tempDir.path}" "${this._execPath}" "${this._srtPath}" --settings "${this._sandboxConfigPath}" -c ${this._quoteShellArgument(commandToRunInSandbox)}`; + const wrappedCommand = this._os === OperatingSystem.Linux && cwd?.path && cwd.path !== this._tempDir.path + ? `cd ${this._quoteShellArgument(this._tempDir.path)}; ${sandboxRuntimeCommand}` + : sandboxRuntimeCommand; if (this._remoteEnvDetails) { return { command: wrappedCommand, @@ -390,6 +412,13 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb return `'${value.replace(/'/g, `'\\''`)}'`; } + private _getSandboxCommandWithPreservedCwd(command: string, cwd: URI | undefined): string { + if (this._os !== OperatingSystem.Linux || !cwd?.path || cwd.path === this._tempDir?.path) { + return command; + } + return `cd ${this._quoteShellArgument(cwd.path)} && ${command}`; + } + private _wrapUnsandboxedCommand(command: string, shell?: string): string { if (!this._tempDir?.path) { return command; @@ -465,6 +494,14 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb } } + private _normalizeCommandKeywords(commandKeywords: readonly string[]): string[] { + return [...new Set(commandKeywords.map(keyword => keyword.toLowerCase()))].sort(); + } + + private _areCommandKeywordsEqual(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((keyword, index) => keyword === b[index]); + } + private async _isSandboxConfiguredEnabled(): Promise { const os = await this.getOS(); if (os === OperatingSystem.Windows) { @@ -496,25 +533,38 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb const allowedDomainsSetting = this._getSettingValue(AgentNetworkDomainSettingId.AllowedNetworkDomains, AgentNetworkDomainSettingId.DeprecatedSandboxAllowedNetworkDomains, AgentNetworkDomainSettingId.DeprecatedOldAllowedNetworkDomains) ?? []; const deniedDomainsSetting = this._getSettingValue(AgentNetworkDomainSettingId.DeniedNetworkDomains, AgentNetworkDomainSettingId.DeprecatedSandboxDeniedNetworkDomains, AgentNetworkDomainSettingId.DeprecatedOldDeniedNetworkDomains) ?? []; const linuxFileSystemSetting = this._os === OperatingSystem.Linux - ? this._getSettingValue<{ denyRead?: string[]; allowWrite?: string[]; denyWrite?: string[] }>(TerminalChatAgentToolsSettingId.AgentSandboxLinuxFileSystem, TerminalChatAgentToolsSettingId.DeprecatedAgentSandboxLinuxFileSystem) ?? {} + ? this._getSettingValue(TerminalChatAgentToolsSettingId.AgentSandboxLinuxFileSystem, TerminalChatAgentToolsSettingId.DeprecatedAgentSandboxLinuxFileSystem) ?? {} : {}; const macFileSystemSetting = this._os === OperatingSystem.Macintosh - ? this._getSettingValue<{ denyRead?: string[]; allowWrite?: string[]; denyWrite?: string[] }>(TerminalChatAgentToolsSettingId.AgentSandboxMacFileSystem, TerminalChatAgentToolsSettingId.DeprecatedAgentSandboxMacFileSystem) ?? {} + ? this._getSettingValue(TerminalChatAgentToolsSettingId.AgentSandboxMacFileSystem, TerminalChatAgentToolsSettingId.DeprecatedAgentSandboxMacFileSystem) ?? {} : {}; const runtimeSetting = this._getSettingValue>(TerminalChatAgentToolsSettingId.AgentSandboxAdvancedRuntime) ?? {}; const configFileUri = URI.joinPath(this._tempDir, `vscode-sandbox-settings-${this._sandboxSettingsId}.json`); - const linuxAllowWrite = this._updateAllowWritePathsWithWorkspaceFolders(linuxFileSystemSetting.allowWrite); - const macAllowWrite = this._updateAllowWritePathsWithWorkspaceFolders(macFileSystemSetting.allowWrite); - + let allowWritePaths: string[] = []; + let allowReadPaths: string[] = []; + let denyReadPaths: string[] = []; + let denyWritePaths: string[] | undefined; + if (this._os === OperatingSystem.Macintosh) { + allowWritePaths = this._updateAllowWritePathsWithWorkspaceFolders(macFileSystemSetting.allowWrite); + allowReadPaths = this._updateAllowReadPathsWithAllowWrite(macFileSystemSetting.allowRead, allowWritePaths); + denyReadPaths = this._updateDenyReadPathsWithHome(macFileSystemSetting.denyRead); + denyWritePaths = macFileSystemSetting.denyWrite; + } else if (this._os === OperatingSystem.Linux) { + allowWritePaths = this._resolveLinuxFileSystemPaths(this._updateAllowWritePathsWithWorkspaceFolders(linuxFileSystemSetting.allowWrite)); + allowReadPaths = this._resolveLinuxFileSystemPaths(this._updateAllowReadPathsWithAllowWrite(linuxFileSystemSetting.allowRead, allowWritePaths)); + denyReadPaths = this._resolveLinuxFileSystemPaths(this._updateDenyReadPathsWithHome(linuxFileSystemSetting.denyRead)); + denyWritePaths = this._resolveLinuxFileSystemPaths(linuxFileSystemSetting.denyWrite); + } const sandboxSettings = { network: { allowedDomains: allowedDomainsSetting, deniedDomains: deniedDomainsSetting }, filesystem: { - denyRead: this._os === OperatingSystem.Macintosh ? macFileSystemSetting.denyRead : linuxFileSystemSetting.denyRead, - allowWrite: this._os === OperatingSystem.Macintosh ? macAllowWrite : linuxAllowWrite, - denyWrite: this._os === OperatingSystem.Macintosh ? macFileSystemSetting.denyWrite : linuxFileSystemSetting.denyWrite, + denyRead: denyReadPaths, + allowRead: allowReadPaths, + allowWrite: allowWritePaths, + denyWrite: denyWritePaths, }, }; this._mergeAdditionalSandboxConfigProperties(sandboxSettings as Record, runtimeSetting); @@ -611,6 +661,54 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb return [...new Set([...workspaceFolderPaths, ...this._defaultWritePaths, ...(configuredAllowWrite ?? [])])]; } + private _updateDenyReadPathsWithHome(configuredDenyRead: string[] | undefined): string[] { + const userHome = this._getUserHomePath(); + return [...new Set([...(configuredDenyRead ?? []), ...(userHome ? [userHome] : [])])]; + } + + private _updateAllowReadPathsWithAllowWrite(configuredAllowRead: string[] | undefined, allowWrite: string[]): string[] { + return [...new Set([...(configuredAllowRead ?? []), ...getTerminalSandboxReadAllowListForCommands(this._os, this._commandReadAllowKeywords), ...this._getSandboxRuntimeReadPaths(), ...allowWrite])]; + } + + private _resolveLinuxFileSystemPaths(paths: string[] | undefined): string[] { + return (paths ?? []).map(path => this._expandHomePath(path)); + } + + private _expandHomePath(path: string): string { + const userHome = this._getUserHomePath(); + if (!userHome) { + return path; + } + if (path === '~') { + return userHome; + } + if (path.startsWith('~/')) { + return this._pathJoin(userHome, path.slice(2)); + } + return path; + } + + private _getSandboxRuntimeReadPaths(): string[] { + const paths: string[] = [this._appRoot]; + if (this._execPath) { + for (const path of [this._execPath, dirname(this._execPath)]) { + if (!this._isPathUnderAppRoot(path)) { + paths.push(path); + } + } + } + return paths; + } + + private _isPathUnderAppRoot(path: string): boolean { + return path === this._appRoot || path.startsWith(`${this._appRoot}${this._os === OperatingSystem.Windows ? win32.sep : posix.sep}`); + } + + private _getUserHomePath(): string | undefined { + const nativeEnv = this._environmentService as IEnvironmentService & { userHome?: URI }; + return this._remoteEnvDetails?.userHome?.path ?? nativeEnv.userHome?.path; + } + private async _resolveSandboxDependencyStatus(forceRefresh = false): Promise { if (!forceRefresh && this._sandboxDependencyStatus) { return this._sandboxDependencyStatus; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxedCommandLinePresenter.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxedCommandLinePresenter.test.ts index ab879894fcbf5..05dfc5a1d93dc 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxedCommandLinePresenter.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxedCommandLinePresenter.test.ts @@ -20,7 +20,7 @@ suite('SandboxedCommandLinePresenter', () => { instantiationService.stub(ITerminalSandboxService, { _serviceBrand: undefined, isEnabled: async () => enabled, - wrapCommand: command => ({ + wrapCommand: async command => ({ command, isSandboxWrapped: false, }), diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts index 691698e4cfa6d..65af98e10d595 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts @@ -5,9 +5,10 @@ import * as assert from 'assert'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; -import { Event } from '../../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; import type { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { SendToTerminalTool, SendToTerminalToolData } from '../../browser/tools/sendToTerminalTool.js'; import { RunInTerminalTool, type IActiveTerminalExecution } from '../../browser/tools/runInTerminalTool.js'; import type { IToolInvocation, IToolInvocationPreparationContext } from '../../../../chat/common/tools/languageModelToolsService.js'; @@ -45,9 +46,9 @@ suite('SendToTerminalTool', () => { RunInTerminalTool.getExecution = originalGetExecution; }); - function createInvocation(id: string, command: string): IToolInvocation { + function createInvocation(id: string, command: string, waitForOutput?: boolean): IToolInvocation { return { - parameters: { id, command }, + parameters: { id, command, ...(waitForOutput !== undefined ? { waitForOutput } : {}) }, callId: 'test-call', context: { sessionId: 'test-session' }, toolId: 'send_to_terminal', @@ -57,17 +58,21 @@ suite('SendToTerminalTool', () => { } as unknown as IToolInvocation; } - function createMockExecution(output: string): IActiveTerminalExecution & { sentTexts: { text: string; shouldExecute: boolean; forceBracketedPasteMode?: boolean }[] } { + function createMockExecution(output: string): IActiveTerminalExecution & { sentTexts: { text: string; shouldExecute: boolean; forceBracketedPasteMode?: boolean }[]; dataEmitter: Emitter } { const sentTexts: { text: string; shouldExecute: boolean; forceBracketedPasteMode?: boolean }[] = []; + const dataEmitter = store.add(new Emitter()); return { completionPromise: Promise.resolve({ output } as ITerminalExecuteStrategyResult), instance: { sendText: async (text: string, shouldExecute: boolean, forceBracketedPasteMode?: boolean) => { sentTexts.push({ text, shouldExecute, forceBracketedPasteMode }); }, + registerMarker: () => undefined, + onData: dataEmitter.event, } as unknown as ITerminalInstance, getOutput: () => output, sentTexts, + dataEmitter, }; } @@ -405,6 +410,36 @@ suite('SendToTerminalTool', () => { assert.ok(message.value.includes('Focus Terminal'), 'should contain Focus Terminal link text'); }); + test('tool schema includes waitForOutput parameter', () => { + const waitForOutputProperty = SendToTerminalToolData.inputSchema?.properties?.waitForOutput as { type?: string; description?: string } | undefined; + assert.ok(waitForOutputProperty, 'waitForOutput should be in the schema'); + assert.strictEqual(waitForOutputProperty.type, 'boolean'); + assert.ok(waitForOutputProperty.description?.includes('idle')); + }); + + test('waitForOutput=true waits for idle before returning', async () => { + return runWithFakedTimers({}, async () => { + const mockExecution = createMockExecution('output'); + RunInTerminalTool.getExecution = () => mockExecution; + + // Emit some data shortly after invocation starts, then stop + const dataDelay = setTimeout(() => { + mockExecution.dataEmitter.fire('some response data'); + }, 100); + + const result = await tool.invoke( + createInvocation(KNOWN_TERMINAL_ID, 'look', true), + async () => 0, + { report: () => { } }, + CancellationToken.None, + ); + + clearTimeout(dataDelay); + const value = (result.content[0] as { value: string }).value; + assert.ok(value.includes('Successfully sent command')); + }); + }); + test('preserves newlines for heredoc commands and uses bracketed paste mode', async () => { const mockExecution = createMockExecution('output'); RunInTerminalTool.getExecution = () => mockExecution; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts index c46bea5e06b2b..9462712df7e3c 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts @@ -39,13 +39,16 @@ suite('TerminalSandboxService - network domains', () => { let workspaceContextService: MockWorkspaceContextService; let productService: IProductService; let sandboxHelperService: MockSandboxHelperService; + let remoteAgentService: MockRemoteAgentService; let createdFiles: Map; + let createFileCount: number; let createdFolders: string[]; let deletedFolders: string[]; const windowId = 7; class MockFileService { async createFile(uri: URI, content: VSBuffer): Promise { + createFileCount++; const contentString = content.toString(); createdFiles.set(uri.path, contentString); return {}; @@ -62,37 +65,39 @@ suite('TerminalSandboxService - network domains', () => { } class MockRemoteAgentService { + remoteEnvironment: IRemoteAgentEnvironment | null = { + os: OperatingSystem.Linux, + tmpDir: URI.file('/tmp'), + appRoot: URI.file('/app'), + execPath: '/app/node', + pid: 1234, + connectionToken: 'test-token', + settingsPath: URI.file('/settings'), + mcpResource: URI.file('/mcp'), + logsPath: URI.file('/logs'), + extensionHostLogsPath: URI.file('/ext-logs'), + globalStorageHome: URI.file('/global'), + workspaceStorageHome: URI.file('/workspace'), + localHistoryHome: URI.file('/history'), + userHome: URI.file('/home/user'), + arch: 'x64', + marks: [], + useHostProxy: false, + profiles: { + all: [], + home: URI.file('/profiles') + }, + isUnsupportedGlibc: false + }; + getConnection() { return null; } - async getEnvironment(): Promise { + async getEnvironment(): Promise { // Return a Linux environment to ensure tests pass on Windows // (sandbox is not supported on Windows) - return { - os: OperatingSystem.Linux, - tmpDir: URI.file('/tmp'), - appRoot: URI.file('/app'), - execPath: '/app/node', - pid: 1234, - connectionToken: 'test-token', - settingsPath: URI.file('/settings'), - mcpResource: URI.file('/mcp'), - logsPath: URI.file('/logs'), - extensionHostLogsPath: URI.file('/ext-logs'), - globalStorageHome: URI.file('/global'), - workspaceStorageHome: URI.file('/workspace'), - localHistoryHome: URI.file('/history'), - userHome: URI.file('/home/user'), - arch: 'x64', - marks: [], - useHostProxy: false, - profiles: { - all: [], - home: URI.file('/profiles') - }, - isUnsupportedGlibc: false - }; + return this.remoteEnvironment; } } @@ -160,6 +165,7 @@ suite('TerminalSandboxService - network domains', () => { setup(() => { createdFiles = new Map(); + createFileCount = 0; createdFolders = []; deletedFolders = []; instantiationService = workbenchInstantiationService({}, store); @@ -168,6 +174,7 @@ suite('TerminalSandboxService - network domains', () => { lifecycleService = store.add(new TestLifecycleService()); workspaceContextService = new MockWorkspaceContextService(); sandboxHelperService = new MockSandboxHelperService(); + remoteAgentService = new MockRemoteAgentService(); productService = { ...TestProductService, dataFolderName: '.test-data', @@ -182,15 +189,17 @@ suite('TerminalSandboxService - network domains', () => { instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IFileService, fileService); - instantiationService.stub(IEnvironmentService, { + instantiationService.stub(IEnvironmentService, { _serviceBrand: undefined, tmpDir: URI.file('/tmp'), execPath: '/usr/bin/node', + userHome: URI.file('/home/local-user'), + userDataPath: '/custom/local-user-data', window: { id: windowId } }); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IProductService, productService); - instantiationService.stub(IRemoteAgentService, new MockRemoteAgentService()); + instantiationService.stub(IRemoteAgentService, remoteAgentService); instantiationService.stub(IWorkspaceContextService, workspaceContextService); instantiationService.stub(ILifecycleService, lifecycleService); instantiationService.stub(ISandboxHelperService, sandboxHelperService); @@ -336,6 +345,7 @@ suite('TerminalSandboxService - network domains', () => { configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AgentSandboxLinuxFileSystem, { allowWrite: ['/configured/path'], denyRead: [], + allowRead: ['/configured/readable/path'], denyWrite: [] }); configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AgentSandboxAdvancedRuntime, { @@ -345,6 +355,7 @@ suite('TerminalSandboxService - network domains', () => { }, filesystem: { allowWrite: ['/should-not-win'], + allowRead: ['/should-not-win-readable'], unixSockets: { enabled: true, } @@ -367,12 +378,175 @@ suite('TerminalSandboxService - network domains', () => { }); ok(config.filesystem.allowWrite.includes('/configured/path'), 'Configured filesystem values should be preserved'); ok(!config.filesystem.allowWrite.includes('/should-not-win'), 'Runtime filesystem values should not override schema-defined filesystem config'); + ok(config.filesystem.allowRead.includes('/configured/readable/path'), 'Configured allowRead values should be preserved'); + ok(config.filesystem.allowRead.includes('/workspace-one'), 'Generated allowRead should include workspace folders'); + ok(config.filesystem.allowRead.includes('/configured/path'), 'Generated allowRead should include configured allowWrite paths'); + ok(!config.filesystem.allowRead.includes('/should-not-win-readable'), 'Runtime filesystem allowRead should not override schema-defined filesystem config'); deepStrictEqual(config.filesystem.unixSockets, { enabled: true, }, 'Additional nested runtime filesystem properties should be merged in'); strictEqual(config.allowUnixSockets, true, 'Non-conflicting runtime properties should still be added'); }); + test('should deny home reads while reallowing writable paths for reads', async () => { + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AgentSandboxLinuxFileSystem, { + allowWrite: ['/configured/path'], + denyRead: ['/secret/path'], + allowRead: ['/configured/readable/path'], + denyWrite: [] + }); + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const configContent = createdFiles.get(configPath); + ok(configContent, 'Config file should be created'); + + const config = JSON.parse(configContent); + ok(config.filesystem.denyRead.includes('/home/user'), 'Sandbox config should deny arbitrary reads from the user home'); + ok(config.filesystem.denyRead.includes('/secret/path'), 'Sandbox config should preserve configured denyRead paths'); + ok(config.filesystem.allowRead.includes('/workspace-one'), 'Sandbox config should re-allow reads from workspace folders'); + ok(config.filesystem.allowRead.includes('/configured/path'), 'Sandbox config should re-allow reads from configured allowWrite paths'); + ok(config.filesystem.allowRead.includes('/configured/readable/path'), 'Sandbox config should preserve configured allowRead paths'); + ok(config.filesystem.allowRead.includes('/home/user/.npm'), 'Sandbox config should re-allow reads from default write paths'); + ok(!config.filesystem.allowRead.includes('/home/user/.gitconfig'), 'Sandbox config should not include command-specific git read allow-list paths before a command is parsed'); + ok(!config.filesystem.allowRead.includes('/home/user/.nvm/versions'), 'Sandbox config should not include command-specific node read allow-list paths before a command is parsed'); + ok(!config.filesystem.allowRead.includes('/home/user/.cache/pip'), 'Sandbox config should not include command-specific common dev read allow-list paths before a command is parsed'); + ok(config.filesystem.allowRead.includes('/app'), 'Sandbox config should include the VS Code app root'); + ok(!config.filesystem.allowRead.includes('/app/node'), 'Sandbox config should not redundantly include app root child paths'); + ok(!config.filesystem.allowRead.includes('/app/node_modules'), 'Sandbox config should not redundantly include app root child paths'); + ok(!config.filesystem.allowRead.includes('/app/node_modules/@vscode/ripgrep'), 'Sandbox config should not redundantly include app root child paths'); + }); + + test('should only add command-specific allowRead paths for the current command keywords', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + await sandboxService.wrapCommand('node --version', false, 'bash', ['node']); + const nodeConfigContent = createdFiles.get(configPath); + ok(nodeConfigContent, 'Config file should be rewritten for node commands'); + + const nodeConfig = JSON.parse(nodeConfigContent); + ok(nodeConfig.filesystem.allowRead.includes('/home/user/.nvm/versions'), 'Node commands should include node-specific read allow-list paths'); + ok(!nodeConfig.filesystem.allowRead.includes('/home/user/.gitconfig'), 'Node commands should not include git-specific read allow-list paths'); + + await sandboxService.wrapCommand('git status', false, 'bash', ['git']); + const gitConfigContent = createdFiles.get(configPath); + ok(gitConfigContent, 'Config file should be rewritten for git commands'); + + const gitConfig = JSON.parse(gitConfigContent); + ok(gitConfig.filesystem.allowRead.includes('/home/user/.gitconfig'), 'Git commands should include git-specific read allow-list paths'); + ok(!gitConfig.filesystem.allowRead.includes('/home/user/.nvm/versions'), 'Refreshing for a new command should start allowRead from the current command keywords'); + }); + + test('should not rewrite sandbox config when the parsed command keywords are unchanged', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const initialCreateFileCount = createFileCount; + + await sandboxService.wrapCommand('node --version', false, 'bash', ['node']); + const afterFirstNodeCommandCount = createFileCount; + strictEqual(afterFirstNodeCommandCount, initialCreateFileCount + 1, 'First node command should rewrite the config once'); + + await sandboxService.wrapCommand('node app.js', false, 'bash', ['node']); + strictEqual(createFileCount, afterFirstNodeCommandCount, 'Second node command should not rewrite the config when keywords are unchanged'); + }); + + test('should expand home paths in linux filesystem sandbox config paths', async () => { + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AgentSandboxLinuxFileSystem, { + allowWrite: ['~/.custom-write', '/glob/**/*.ts'], + denyRead: ['~/.secret', '/secret/*'], + allowRead: ['~/.custom-readable', '/readable/{a,b}'], + denyWrite: ['~/.custom-write/file.txt', '/configured/path/file?.txt'] + }); + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const configContent = createdFiles.get(configPath); + ok(configContent, 'Config file should be created'); + + const config = JSON.parse(configContent); + ok(config.filesystem.allowWrite.includes('/home/user/.custom-write'), 'allowWrite should expand home paths on Linux'); + ok(config.filesystem.allowWrite.includes('/glob/**/*.ts'), 'Non-home allowWrite paths should be preserved'); + ok(!config.filesystem.allowWrite.includes('~/.custom-write'), 'allowWrite should not include unexpanded home paths on Linux'); + ok(config.filesystem.denyRead.includes('/home/user/.secret'), 'denyRead should expand home paths on Linux'); + ok(config.filesystem.denyRead.includes('/secret/*'), 'Non-home denyRead paths should be preserved'); + ok(!config.filesystem.denyRead.includes('~/.secret'), 'denyRead should not include unexpanded home paths on Linux'); + ok(config.filesystem.allowRead.includes('/home/user/.custom-readable'), 'allowRead should expand home paths on Linux'); + ok(config.filesystem.allowRead.includes('/readable/{a,b}'), 'Non-home allowRead paths should be preserved'); + ok(!config.filesystem.allowRead.includes('~/.custom-readable'), 'allowRead should not include unexpanded home paths on Linux'); + ok(config.filesystem.denyWrite.includes('/home/user/.custom-write/file.txt'), 'denyWrite should expand home paths on Linux'); + ok(config.filesystem.denyWrite.includes('/configured/path/file?.txt'), 'Non-home denyWrite paths should be preserved'); + ok(!config.filesystem.denyWrite.includes('~/.custom-write/file.txt'), 'denyWrite should not include unexpanded home paths on Linux'); + }); + + test('should deny home reads while reallowing writable paths for reads on macOS', async () => { + remoteAgentService.remoteEnvironment = { + ...remoteAgentService.remoteEnvironment!, + os: OperatingSystem.Macintosh + }; + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AgentSandboxMacFileSystem, { + allowWrite: ['/configured/path'], + denyRead: ['/secret/path'], + allowRead: ['/configured/readable/path'], + denyWrite: [] + }); + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const configContent = createdFiles.get(configPath); + ok(configContent, 'Config file should be created'); + + const config = JSON.parse(configContent); + ok(config.filesystem.denyRead.includes('/home/user'), 'Sandbox config should deny arbitrary reads from the user home on macOS'); + ok(config.filesystem.denyRead.includes('/secret/path'), 'Sandbox config should preserve configured denyRead paths on macOS'); + ok(config.filesystem.allowRead.includes('/workspace-one'), 'Sandbox config should re-allow reads from workspace folders on macOS'); + ok(config.filesystem.allowRead.includes('/configured/path'), 'Sandbox config should re-allow reads from configured allowWrite paths on macOS'); + ok(config.filesystem.allowRead.includes('/configured/readable/path'), 'Sandbox config should preserve configured allowRead paths on macOS'); + }); + + test('should not expand home paths in macOS filesystem sandbox config paths', async () => { + remoteAgentService.remoteEnvironment = { + ...remoteAgentService.remoteEnvironment!, + os: OperatingSystem.Macintosh + }; + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AgentSandboxMacFileSystem, { + allowWrite: ['~/.custom-write', '/glob/**/*.ts'], + denyRead: ['~/.secret', '/secret/*'], + allowRead: ['~/.custom-readable', '/readable/{a,b}'], + denyWrite: ['~/.custom-write/file.txt', '/configured/path/file?.txt'] + }); + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const configContent = createdFiles.get(configPath); + ok(configContent, 'Config file should be created'); + + const config = JSON.parse(configContent); + ok(config.filesystem.allowWrite.includes('~/.custom-write'), 'allowWrite should preserve unexpanded home paths on macOS'); + ok(config.filesystem.allowWrite.includes('/glob/**/*.ts'), 'Non-home allowWrite paths should be preserved on macOS'); + ok(!config.filesystem.allowWrite.includes('/home/user/.custom-write'), 'allowWrite should not expand ~ on macOS'); + ok(config.filesystem.denyRead.includes('~/.secret'), 'denyRead should preserve unexpanded home paths on macOS'); + ok(config.filesystem.denyRead.includes('/secret/*'), 'Non-home denyRead paths should be preserved on macOS'); + ok(!config.filesystem.denyRead.includes('/home/user/.secret'), 'denyRead should not expand ~ on macOS'); + ok(config.filesystem.allowRead.includes('~/.custom-readable'), 'allowRead should preserve unexpanded home paths on macOS'); + ok(config.filesystem.allowRead.includes('/readable/{a,b}'), 'Non-home allowRead paths should be preserved on macOS'); + ok(!config.filesystem.allowRead.includes('/home/user/.custom-readable'), 'allowRead should not expand ~ on macOS'); + ok(config.filesystem.denyWrite.includes('~/.custom-write/file.txt'), 'denyWrite should preserve unexpanded home paths on macOS'); + ok(config.filesystem.denyWrite.includes('/configured/path/file?.txt'), 'Non-home denyWrite paths should be preserved on macOS'); + ok(!config.filesystem.denyWrite.includes('/home/user/.custom-write/file.txt'), 'denyWrite should not expand ~ on macOS'); + }); + test('should refresh allowWrite paths when workspace folders change', async () => { configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AgentSandboxLinuxFileSystem, { allowWrite: ['/configured/path'], @@ -390,6 +564,9 @@ suite('TerminalSandboxService - network domains', () => { const initialConfig = JSON.parse(initialConfigContent); ok(initialConfig.filesystem.allowWrite.includes('/workspace-one'), 'Initial config should include the original workspace folder'); ok(initialConfig.filesystem.allowWrite.includes('/configured/path'), 'Initial config should include configured allowWrite paths'); + ok(initialConfig.filesystem.denyRead.includes('/home/user'), 'Initial config should deny arbitrary reads from home'); + ok(initialConfig.filesystem.allowRead.includes('/workspace-one'), 'Initial config should re-allow reading the original workspace folder'); + ok(initialConfig.filesystem.allowRead.includes('/configured/path'), 'Initial config should re-allow reading configured allowWrite paths'); workspaceContextService.setWorkspaceFolders([URI.file('/workspace-two')]); @@ -403,6 +580,10 @@ suite('TerminalSandboxService - network domains', () => { ok(refreshedConfig.filesystem.allowWrite.includes('/workspace-two'), 'Refreshed config should include the updated workspace folder'); ok(!refreshedConfig.filesystem.allowWrite.includes('/workspace-one'), 'Refreshed config should remove the old workspace folder'); ok(refreshedConfig.filesystem.allowWrite.includes('/configured/path'), 'Refreshed config should preserve configured allowWrite paths'); + ok(refreshedConfig.filesystem.denyRead.includes('/home/user'), 'Refreshed config should continue to deny arbitrary reads from home'); + ok(refreshedConfig.filesystem.allowRead.includes('/workspace-two'), 'Refreshed config should re-allow reading the updated workspace folder'); + ok(!refreshedConfig.filesystem.allowRead.includes('/workspace-one'), 'Refreshed config should remove the old workspace folder from allowRead'); + ok(refreshedConfig.filesystem.allowRead.includes('/configured/path'), 'Refreshed config should preserve configured allowWrite paths in allowRead'); }); test('should create sandbox temp dir under the server data folder', async () => { @@ -431,7 +612,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrappedCommand = sandboxService.wrapCommand('echo test'); + const wrappedCommand = await sandboxService.wrapCommand('echo test'); ok( wrappedCommand.command.includes('PATH') && wrappedCommand.command.includes('ripgrep'), @@ -440,39 +621,51 @@ suite('TerminalSandboxService - network domains', () => { strictEqual(wrappedCommand.isSandboxWrapped, true, 'Command should stay sandbox wrapped when no domain is detected'); }); + test('should launch Linux sandbox runtime from temp dir while preserving the command cwd', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + + const wrapResult = await sandboxService.wrapCommand('head -1 /etc/shells', false, 'bash', undefined, URI.file('/workspace-one')); + const expectedWrappedCwd = String.raw`-c 'cd '\''/workspace-one'\'' && head -1 /etc/shells'`; + + ok(wrapResult.command.startsWith(`cd '${sandboxService.getTempDir()?.path}'; `), 'Sandbox runtime should start from the sandbox temp dir on Linux'); + ok(wrapResult.command.includes(expectedWrappedCwd), `Sandboxed command should restore the original cwd before running the user command. Actual: ${wrapResult.command}`); + strictEqual(wrapResult.isSandboxWrapped, true, 'Command should remain sandbox wrapped'); + }); + test('should preserve TMPDIR when unsandboxed execution is requested', async () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - strictEqual(sandboxService.wrapCommand('echo test', true, 'bash').command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'echo test'`); + strictEqual((await sandboxService.wrapCommand('echo test', true, 'bash')).command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'echo test'`); }); test('should preserve TMPDIR for piped unsandboxed commands', async () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - strictEqual(sandboxService.wrapCommand('echo test | cat', true, 'bash').command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'echo test | cat'`); + strictEqual((await sandboxService.wrapCommand('echo test | cat', true, 'bash')).command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'echo test | cat'`); }); test('should preserve trailing backslashes for unsandboxed commands', async () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - strictEqual(sandboxService.wrapCommand('echo test \\', true, 'bash').command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'echo test \\'`); + strictEqual((await sandboxService.wrapCommand('echo test \\', true, 'bash')).command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'echo test \\'`); }); test('should use fish-compatible wrapping for unsandboxed commands', async () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - strictEqual(sandboxService.wrapCommand('echo test', true, 'fish').command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'fish' -c 'echo test'`); + strictEqual((await sandboxService.wrapCommand('echo test', true, 'fish')).command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'fish' -c 'echo test'`); }); test('should switch to unsandboxed execution when a domain is not allowlisted', async () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrapResult = sandboxService.wrapCommand('curl https://example.com', false, 'bash'); + const wrapResult = await sandboxService.wrapCommand('curl https://example.com', false, 'bash'); strictEqual(wrapResult.isSandboxWrapped, false, 'Blocked domains should prevent sandbox wrapping'); strictEqual(wrapResult.requiresUnsandboxConfirmation, true, 'Blocked domains should require unsandbox confirmation'); @@ -485,7 +678,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrapResult = sandboxService.wrapCommand('curl https://example.com'); + const wrapResult = await sandboxService.wrapCommand('curl https://example.com'); strictEqual(wrapResult.isSandboxWrapped, true, 'Exact allowlisted domains should stay sandboxed'); strictEqual(wrapResult.blockedDomains, undefined, 'Allowed domains should not be reported as blocked'); @@ -496,7 +689,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrapResult = sandboxService.wrapCommand('curl "https://api.github.com/repos/microsoft/vscode"'); + const wrapResult = await sandboxService.wrapCommand('curl "https://api.github.com/repos/microsoft/vscode"'); strictEqual(wrapResult.isSandboxWrapped, true, 'Wildcard allowlisted domains should stay sandboxed'); strictEqual(wrapResult.blockedDomains, undefined, 'Wildcard allowlisted domains should not be reported as blocked'); @@ -508,7 +701,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrapResult = sandboxService.wrapCommand('curl https://api.github.com/repos/microsoft/vscode'); + const wrapResult = await sandboxService.wrapCommand('curl https://api.github.com/repos/microsoft/vscode'); strictEqual(wrapResult.isSandboxWrapped, false, 'Denied domains should not stay sandboxed'); deepStrictEqual(wrapResult.blockedDomains, ['api.github.com']); @@ -520,7 +713,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrapResult = sandboxService.wrapCommand('curl https://API.GITHUB.COM/repos/microsoft/vscode'); + const wrapResult = await sandboxService.wrapCommand('curl https://API.GITHUB.COM/repos/microsoft/vscode'); strictEqual(wrapResult.isSandboxWrapped, true, 'Uppercase hostnames should still match allowlisted domains'); strictEqual(wrapResult.blockedDomains, undefined, 'Uppercase allowlisted domains should not be reported as blocked'); @@ -530,7 +723,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrapResult = sandboxService.wrapCommand('curl https://example.com]/path'); + const wrapResult = await sandboxService.wrapCommand('curl https://example.com]/path'); strictEqual(wrapResult.isSandboxWrapped, true, 'Malformed URL authorities should not trigger blocked-domain prompts'); strictEqual(wrapResult.blockedDomains, undefined, 'Malformed URL authorities should be ignored'); @@ -540,11 +733,11 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const javascriptResult = sandboxService.wrapCommand('cat bundle.js', false, 'bash'); + const javascriptResult = await sandboxService.wrapCommand('cat bundle.js', false, 'bash'); strictEqual(javascriptResult.isSandboxWrapped, true, 'File extensions such as .js should not trigger blocked-domain prompts'); strictEqual(javascriptResult.blockedDomains, undefined, 'File extensions such as .js should not be reported as domains'); - const jsonResult = sandboxService.wrapCommand('cat package.json', false, 'bash'); + const jsonResult = await sandboxService.wrapCommand('cat package.json', false, 'bash'); strictEqual(jsonResult.isSandboxWrapped, true, 'File extensions such as .json should not trigger blocked-domain prompts'); strictEqual(jsonResult.blockedDomains, undefined, 'File extensions such as .json should not be reported as domains'); }); @@ -560,7 +753,7 @@ suite('TerminalSandboxService - network domains', () => { ]; for (const command of commands) { - const wrapResult = sandboxService.wrapCommand(command, false, 'bash'); + const wrapResult = await sandboxService.wrapCommand(command, false, 'bash'); strictEqual(wrapResult.isSandboxWrapped, true, `Command ${command} should remain sandboxed`); strictEqual(wrapResult.blockedDomains, undefined, `Command ${command} should not report a blocked domain`); } @@ -570,11 +763,11 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const testComResult = sandboxService.wrapCommand('curl test.com', false, 'bash'); + const testComResult = await sandboxService.wrapCommand('curl test.com', false, 'bash'); strictEqual(testComResult.isSandboxWrapped, false, 'Well-known bare domain suffixes should trigger domain checks'); deepStrictEqual(testComResult.blockedDomains, ['test.com']); - const testOrgComResult = sandboxService.wrapCommand('curl test.org.com', false, 'bash'); + const testOrgComResult = await sandboxService.wrapCommand('curl test.org.com', false, 'bash'); strictEqual(testOrgComResult.isSandboxWrapped, false, 'Well-known bare domain suffixes should trigger domain checks for multi-label hosts'); deepStrictEqual(testOrgComResult.blockedDomains, ['test.org.com']); }); @@ -583,7 +776,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrapResult = sandboxService.wrapCommand('curl https://example.zip/path', false, 'bash'); + const wrapResult = await sandboxService.wrapCommand('curl https://example.zip/path', false, 'bash'); strictEqual(wrapResult.isSandboxWrapped, false, 'URL authorities should still trigger blocked-domain prompts even when their suffix looks like a file extension'); deepStrictEqual(wrapResult.blockedDomains, ['example.zip']); @@ -593,7 +786,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrapResult = sandboxService.wrapCommand('curl https://example.bar/path', false, 'bash'); + const wrapResult = await sandboxService.wrapCommand('curl https://example.bar/path', false, 'bash'); strictEqual(wrapResult.isSandboxWrapped, false, 'URL authorities should not require a well-known bare-host suffix'); deepStrictEqual(wrapResult.blockedDomains, ['example.bar']); @@ -603,7 +796,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrapResult = sandboxService.wrapCommand('git clone git@example.zip:owner/repo.git', false, 'bash'); + const wrapResult = await sandboxService.wrapCommand('git clone git@example.zip:owner/repo.git', false, 'bash'); strictEqual(wrapResult.isSandboxWrapped, false, 'SSH remotes should still trigger blocked-domain prompts even when their suffix looks like a file extension'); deepStrictEqual(wrapResult.blockedDomains, ['example.zip']); @@ -623,7 +816,7 @@ suite('TerminalSandboxService - network domains', () => { ]; for (const command of commands) { - const wrapResult = sandboxService.wrapCommand(command, false, 'bash'); + const wrapResult = await sandboxService.wrapCommand(command, false, 'bash'); strictEqual(wrapResult.isSandboxWrapped, true, `Command ${command} should remain sandboxed`); strictEqual(wrapResult.blockedDomains, undefined, `Command ${command} should not report a blocked domain`); } @@ -707,7 +900,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrapResult = sandboxService.wrapCommand('git clone git@github.com:microsoft/vscode.git'); + const wrapResult = await sandboxService.wrapCommand('git clone git@github.com:microsoft/vscode.git'); strictEqual(wrapResult.isSandboxWrapped, false, 'SSH-style remotes should trigger domain checks'); deepStrictEqual(wrapResult.blockedDomains, ['github.com']); @@ -718,7 +911,7 @@ suite('TerminalSandboxService - network domains', () => { await sandboxService.getSandboxConfigPath(); const command = '";echo SANDBOX_ESCAPE_REPRO; # $(uname) `id`'; - const wrappedCommand = sandboxService.wrapCommand(command).command; + const wrappedCommand = (await sandboxService.wrapCommand(command)).command; ok( wrappedCommand.includes(`-c '";echo SANDBOX_ESCAPE_REPRO; # $(uname) \`id\`'`), @@ -735,7 +928,7 @@ suite('TerminalSandboxService - network domains', () => { await sandboxService.getSandboxConfigPath(); const command = 'echo $HOME $(printf literal) `id`'; - const wrappedCommand = sandboxService.wrapCommand(command).command; + const wrappedCommand = (await sandboxService.wrapCommand(command)).command; ok( wrappedCommand.includes(`-c 'echo $HOME $(printf literal) \`id\`'`), @@ -752,7 +945,7 @@ suite('TerminalSandboxService - network domains', () => { await sandboxService.getSandboxConfigPath(); const command = 'echo $HOME $(curl eth0.me) `id`'; - const wrapResult = sandboxService.wrapCommand(command, false, 'bash'); + const wrapResult = await sandboxService.wrapCommand(command, false, 'bash'); strictEqual(wrapResult.isSandboxWrapped, false, 'Commands with blocked domains inside substitutions should not stay sandboxed'); strictEqual(wrapResult.requiresUnsandboxConfirmation, true, 'Blocked domains inside substitutions should require confirmation'); @@ -765,7 +958,7 @@ suite('TerminalSandboxService - network domains', () => { await sandboxService.getSandboxConfigPath(); const command = `';printf breakout; #'`; - const wrappedCommand = sandboxService.wrapCommand(command).command; + const wrappedCommand = (await sandboxService.wrapCommand(command)).command; ok( wrappedCommand.includes(`-c '`), @@ -786,7 +979,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrappedCommand = sandboxService.wrapCommand(`echo 'hello'`).command; + const wrappedCommand = (await sandboxService.wrapCommand(`echo 'hello'`)).command; strictEqual((wrappedCommand.match(/\\''/g) ?? []).length, 2, 'Single quote escapes should be inserted for each embedded single quote'); }); }); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineSandboxRewriter.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineSandboxRewriter.test.ts index 10cf4d3d03a4a..8515e5445095f 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineSandboxRewriter.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineSandboxRewriter.test.ts @@ -5,24 +5,29 @@ import { strictEqual, deepStrictEqual } from 'assert'; import { OperatingSystem } from '../../../../../../base/common/platform.js'; +import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import type { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { CommandLineSandboxRewriter } from '../../browser/tools/commandLineRewriter/commandLineSandboxRewriter.js'; import type { ICommandLineRewriterOptions } from '../../browser/tools/commandLineRewriter/commandLineRewriter.js'; +import type { TreeSitterCommandParser } from '../../browser/treeSitterCommandParser.js'; import { ITerminalSandboxService, TerminalSandboxPrerequisiteCheck } from '../../common/terminalSandboxService.js'; suite('CommandLineSandboxRewriter', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); let instantiationService: TestInstantiationService; + const stubTreeSitterCommandParser = (keywords: string[] = []): TreeSitterCommandParser => ({ + extractCommandKeywords: async () => keywords, + } as unknown as TreeSitterCommandParser); const stubSandboxService = (overrides: Partial = {}) => { instantiationService = workbenchInstantiationService({}, store); instantiationService.stub(ITerminalSandboxService, { _serviceBrand: undefined, isEnabled: async () => false, - wrapCommand: (command, _requestUnsandboxedExecution) => { + wrapCommand: async (command, _requestUnsandboxedExecution) => { return { command, isSandboxWrapped: false, @@ -47,21 +52,21 @@ suite('CommandLineSandboxRewriter', () => { test('returns undefined when sandbox is disabled', async () => { stubSandboxService(); - const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter)); + const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter, stubTreeSitterCommandParser())); const result = await rewriter.rewrite(createRewriteOptions('echo hello')); strictEqual(result, undefined); }); test('returns undefined when sandbox config is unavailable', async () => { stubSandboxService({ - wrapCommand: command => ({ + wrapCommand: async command => ({ command: `wrapped:${command}`, isSandboxWrapped: true, }), checkForSandboxingPrereqs: async () => ({ enabled: false, sandboxConfigPath: undefined, failedCheck: TerminalSandboxPrerequisiteCheck.Config }), }); - const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter)); + const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter, stubTreeSitterCommandParser())); const result = await rewriter.rewrite(createRewriteOptions('echo hello')); strictEqual(result, undefined); }); @@ -76,7 +81,7 @@ suite('CommandLineSandboxRewriter', () => { }), }); - const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter)); + const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter, stubTreeSitterCommandParser())); const result = await rewriter.rewrite(createRewriteOptions('echo hello')); strictEqual(result, undefined); }); @@ -84,8 +89,8 @@ suite('CommandLineSandboxRewriter', () => { test('wraps command when sandbox is enabled and config exists', async () => { const calls: string[] = []; stubSandboxService({ - wrapCommand: (command, _requestUnsandboxedExecution) => { - calls.push('wrapCommand'); + wrapCommand: async (command, _requestUnsandboxedExecution, _shell, commandKeywords, cwd) => { + calls.push(`wrapCommand:${commandKeywords?.join(',') ?? ''}:${cwd?.path ?? ''}`); return { command: `wrapped:${command}`, isSandboxWrapped: true, @@ -97,18 +102,22 @@ suite('CommandLineSandboxRewriter', () => { }, }); - const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter)); - const result = await rewriter.rewrite(createRewriteOptions('echo hello')); + const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter, stubTreeSitterCommandParser(['node']))); + const result = await rewriter.rewrite({ + ...createRewriteOptions('echo hello'), + cwd: URI.file('/workspace') + }); strictEqual(result?.rewritten, 'wrapped:echo hello'); strictEqual(result?.reasoning, 'Wrapped command for sandbox execution'); - deepStrictEqual(calls, ['checkForSandboxingPrereqs', 'wrapCommand']); + deepStrictEqual(calls, ['checkForSandboxingPrereqs', 'wrapCommand:node:/workspace']); }); test('wraps command and forwards sandbox bypass flag when explicitly requested', async () => { const calls: string[] = []; stubSandboxService({ - wrapCommand: (command, requestUnsandboxedExecution) => { + wrapCommand: async (command, requestUnsandboxedExecution, _shell, commandKeywords) => { calls.push(`wrap:${command}:${String(requestUnsandboxedExecution)}`); + calls.push(`keywords:${commandKeywords?.join(',') ?? ''}`); return { command: `wrapped:${command}`, isSandboxWrapped: !requestUnsandboxedExecution, @@ -120,7 +129,7 @@ suite('CommandLineSandboxRewriter', () => { }, }); - const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter)); + const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter, stubTreeSitterCommandParser(['git']))); const result = await rewriter.rewrite({ ...createRewriteOptions('echo hello'), requestUnsandboxedExecution: true, @@ -128,6 +137,6 @@ suite('CommandLineSandboxRewriter', () => { strictEqual(result?.rewritten, 'wrapped:echo hello'); strictEqual(result?.reasoning, 'Wrapped command for sandbox execution'); - deepStrictEqual(calls, ['prereqs', 'wrap:echo hello:true']); + deepStrictEqual(calls, ['prereqs', 'wrap:echo hello:true', 'keywords:']); }); }); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index e30ef1be5965a..d8bb868e65dce 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -181,7 +181,7 @@ suite('RunInTerminalTool', () => { terminalSandboxService = { _serviceBrand: undefined, isEnabled: async () => sandboxEnabled, - wrapCommand: (command: string, requestUnsandboxedExecution?: boolean) => ({ + wrapCommand: async (command: string, requestUnsandboxedExecution?: boolean) => ({ command: requestUnsandboxedExecution ? `unsandboxed:${command}` : `sandbox:${command}`, isSandboxWrapped: !requestUnsandboxedExecution, }), @@ -399,7 +399,7 @@ suite('RunInTerminalTool', () => { sandboxConfigPath: '/tmp/vscode-sandbox-settings.json', failedCheck: undefined, }; - terminalSandboxService.wrapCommand = (command: string) => ({ + terminalSandboxService.wrapCommand = async (command: string) => ({ command: `sandbox-runtime ${command}`, isSandboxWrapped: true, }); @@ -422,7 +422,7 @@ suite('RunInTerminalTool', () => { sandboxConfigPath: '/tmp/vscode-sandbox-settings.json', failedCheck: undefined, }; - terminalSandboxService.wrapCommand = (command: string) => ({ + terminalSandboxService.wrapCommand = async (command: string) => ({ command: `sandbox-runtime ${command}`, isSandboxWrapped: true, }); @@ -812,7 +812,7 @@ suite('RunInTerminalTool', () => { failedCheck: undefined, }; runInTerminalTool.setBackendOs(OperatingSystem.Linux); - terminalSandboxService.wrapCommand = (command: string) => ({ + terminalSandboxService.wrapCommand = async (command: string) => ({ command: `unsandboxed:${command}`, isSandboxWrapped: false, requiresUnsandboxConfirmation: true, @@ -2401,7 +2401,7 @@ suite('ChatAgentToolsContribution - tool registration refresh', () => { const terminalSandboxService: ITerminalSandboxService = { _serviceBrand: undefined, isEnabled: async () => sandboxEnabled, - wrapCommand: (command: string) => ({ + wrapCommand: async (command: string) => ({ command: `sandbox:${command}`, isSandboxWrapped: true, }), diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/treeSitterCommandParser.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/treeSitterCommandParser.test.ts index 1e676c46a9bc8..cb4fd487ddab4 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/treeSitterCommandParser.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/treeSitterCommandParser.test.ts @@ -215,6 +215,25 @@ suite('TreeSitterCommandParser', () => { }); }); + suite('extractCommandKeywords', () => { + async function t(languageId: TreeSitterCommandParserLanguage, commandLine: string, expectedKeywords: string[]) { + const result = await parser.extractCommandKeywords(languageId, commandLine); + deepStrictEqual(result, expectedKeywords); + } + + test('extracts bash command keywords from compound commands', () => t( + TreeSitterCommandParserLanguage.Bash, + 'VAR=value node --version && git status && /usr/local/bin/python3 -m pytest', + ['node', 'git', 'python3'] + )); + + test('deduplicates similar command keywords', () => t( + TreeSitterCommandParserLanguage.Bash, + 'node --version && /usr/bin/node script.js && npm ci', + ['node', 'npm'] + )); + }); + suite('extractPwshDoubleAmpersandChainOperators', () => { async function t(commandLine: string, expectedMatches: string[]) { const result = await parser.extractPwshDoubleAmpersandChainOperators(commandLine); diff --git a/src/vs/workbench/contrib/webview/browser/resourceLoading.ts b/src/vs/workbench/contrib/webview/browser/resourceLoading.ts index 1c9f470218013..93aee448b07ee 100644 --- a/src/vs/workbench/contrib/webview/browser/resourceLoading.ts +++ b/src/vs/workbench/contrib/webview/browser/resourceLoading.ts @@ -9,6 +9,7 @@ import { isUNC } from '../../../../base/common/extpath.js'; import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { FileOperationError, FileOperationResult, IFileService, IWriteFileOptions } from '../../../../platform/files/common/files.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { getWebviewContentMimeType } from '../../../../platform/webview/common/mimeTypes.js'; @@ -44,17 +45,19 @@ export namespace WebviewResourceResponse { } export async function loadLocalResource( + accessor: ServicesAccessor, requestUri: URI, options: { ifNoneMatch: string | undefined; roots: ReadonlyArray; range?: { readonly start: number; readonly end?: number }; }, - uriIdentityService: IUriIdentityService, - fileService: IFileService, - logService: ILogService, token: CancellationToken, ): Promise { + const uriIdentityService = accessor.get(IUriIdentityService); + const fileService = accessor.get(IFileService); + const logService = accessor.get(ILogService); + const resourceToLoad = getResourceToLoad(requestUri, options.roots, uriIdentityService); logService.trace(`Webview.loadLocalResource - trying to load resource. requestUri=${requestUri}, resourceToLoad=${resourceToLoad}`); diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index d838af9b42e38..e5ca386227a8e 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -25,13 +25,11 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; -import { IFileService } from '../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IRemoteAuthorityResolverService } from '../../../../platform/remote/common/remoteAuthorityResolver.js'; import { ITunnelService } from '../../../../platform/tunnel/common/tunnel.js'; -import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { WebviewPortMappingManager } from '../../../../platform/webview/common/webviewPortMapping.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { decodeAuthority, webviewGenericCspSource, webviewRootResourceAuthority } from '../common/webview.js'; @@ -172,13 +170,11 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi @IContextMenuService contextMenuService: IContextMenuService, @INotificationService notificationService: INotificationService, @IWorkbenchEnvironmentService private readonly _environmentService: IWorkbenchEnvironmentService, - @IFileService private readonly _fileService: IFileService, @ILogService private readonly _logService: ILogService, @IRemoteAuthorityResolverService private readonly _remoteAuthorityResolverService: IRemoteAuthorityResolverService, @ITunnelService private readonly _tunnelService: ITunnelService, - @IInstantiationService instantiationService: IInstantiationService, @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, - @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); @@ -336,7 +332,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi })); if (initInfo.options.enableFindWidget) { - this._webviewFindWidget = this._register(instantiationService.createInstance(WebviewFindWidget, this)); + this._webviewFindWidget = this._register(this._instantiationService.createInstance(WebviewFindWidget, this)); } } @@ -776,11 +772,11 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi private async loadResource(id: number, uri: URI, options: { ifNoneMatch: string | undefined; range?: { readonly start: number; readonly end?: number } }, token: CancellationToken) { try { - const result = await loadLocalResource(uri, { + const result = await this._instantiationService.invokeFunction(loadLocalResource, uri, { ifNoneMatch: options.ifNoneMatch, roots: this._content.options.localResourceRoots || [], range: options.range, - }, this._uriIdentityService, this._fileService, this._logService, token); + }, token); switch (result.type) { case WebviewResourceResponse.Type.Success: { diff --git a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts index 59013dfd3d418..ceea64ff9e0b6 100644 --- a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts @@ -9,7 +9,6 @@ import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; -import { IFileService } from '../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -17,7 +16,6 @@ import { INativeHostService } from '../../../../platform/native/common/native.js import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IRemoteAuthorityResolverService } from '../../../../platform/remote/common/remoteAuthorityResolver.js'; import { ITunnelService } from '../../../../platform/tunnel/common/tunnel.js'; -import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { FindInFrameOptions, IWebviewManagerService } from '../../../../platform/webview/common/webviewManagerService.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { WebviewThemeDataProvider } from '../browser/themeing.js'; @@ -45,7 +43,6 @@ export class ElectronWebviewElement extends WebviewElement { webviewThemeDataProvider: WebviewThemeDataProvider, @IContextMenuService contextMenuService: IContextMenuService, @ITunnelService tunnelService: ITunnelService, - @IFileService fileService: IFileService, @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, @IRemoteAuthorityResolverService remoteAuthorityResolverService: IRemoteAuthorityResolverService, @ILogService logService: ILogService, @@ -55,11 +52,10 @@ export class ElectronWebviewElement extends WebviewElement { @INativeHostService private readonly _nativeHostService: INativeHostService, @IInstantiationService instantiationService: IInstantiationService, @IAccessibilityService accessibilityService: IAccessibilityService, - @IUriIdentityService uriIdentityService: IUriIdentityService, ) { super(initInfo, webviewThemeDataProvider, configurationService, contextMenuService, notificationService, environmentService, - fileService, logService, remoteAuthorityResolverService, tunnelService, instantiationService, accessibilityService, uriIdentityService); + logService, remoteAuthorityResolverService, tunnelService, accessibilityService, instantiationService); this._webviewKeyboardHandler = new WindowIgnoreMenuShortcutsManager(configurationService, mainProcessService, _nativeHostService); diff --git a/src/vs/workbench/services/environment/browser/environmentService.ts b/src/vs/workbench/services/environment/browser/environmentService.ts index d9e0aeacc107c..60a4e55a7b381 100644 --- a/src/vs/workbench/services/environment/browser/environmentService.ts +++ b/src/vs/workbench/services/environment/browser/environmentService.ts @@ -147,9 +147,6 @@ export class BrowserWorkbenchEnvironmentService implements IBrowserWorkbenchEnvi @memoize get extHostLogsPath(): URI { return joinPath(this.logsHome, 'exthost'); } - @memoize - get agentPluginsHome(): URI { return joinPath(this.userRoamingDataHome, 'agent-plugins'); } - private extensionHostDebugEnvironment: IExtensionHostDebugEnvironment | undefined = undefined; @memoize diff --git a/src/vs/workbench/services/environment/common/environmentService.ts b/src/vs/workbench/services/environment/common/environmentService.ts index 7f799fa21c28e..3ad7edaa43405 100644 --- a/src/vs/workbench/services/environment/common/environmentService.ts +++ b/src/vs/workbench/services/environment/common/environmentService.ts @@ -26,7 +26,6 @@ export interface IWorkbenchEnvironmentService extends IEnvironmentService { readonly logFile: URI; readonly windowLogsPath: URI; readonly extHostLogsPath: URI; - readonly agentPluginsHome: URI; // --- Extensions readonly extensionEnabledProposedApi?: string[]; diff --git a/src/vs/workbench/services/environment/electron-browser/environmentService.ts b/src/vs/workbench/services/environment/electron-browser/environmentService.ts index 8c0080ace5d75..9d08b14460784 100644 --- a/src/vs/workbench/services/environment/electron-browser/environmentService.ts +++ b/src/vs/workbench/services/environment/electron-browser/environmentService.ts @@ -154,9 +154,6 @@ export class NativeWorkbenchEnvironmentService extends AbstractNativeEnvironment @memoize get isSessionsWindow(): boolean { return !!this.configuration.isSessionsWindow; } - @memoize - get agentPluginsHome(): URI { return URI.file(this.agentPluginsPath); } - constructor( private readonly configuration: INativeWindowConfiguration, productService: IProductService diff --git a/src/vs/workbench/services/storage/test/browser/storageService.test.ts b/src/vs/workbench/services/storage/test/browser/storageService.test.ts index 2531f78ca5daf..d995441acf788 100644 --- a/src/vs/workbench/services/storage/test/browser/storageService.test.ts +++ b/src/vs/workbench/services/storage/test/browser/storageService.test.ts @@ -46,7 +46,8 @@ async function createStorageService(): Promise<[DisposableStore, BrowserStorageS snippetsHome: joinPath(inMemoryExtraProfileRoot, 'snippetsHome'), promptsHome: joinPath(inMemoryExtraProfileRoot, 'promptsHome'), extensionsResource: joinPath(inMemoryExtraProfileRoot, 'extensionsResource'), - cacheHome: joinPath(inMemoryExtraProfileRoot, 'cache') + cacheHome: joinPath(inMemoryExtraProfileRoot, 'cache'), + agentPluginsHome: joinPath(inMemoryExtraProfileRoot, 'agentPluginsHome'), }; const storageService = disposables.add(new BrowserStorageService({ id: 'workspace-storage-test' }, disposables.add(new UserDataProfileService(inMemoryExtraProfile)), logService)); diff --git a/src/vs/workbench/services/userDataProfile/browser/userDataProfileImportExportService.ts b/src/vs/workbench/services/userDataProfile/browser/userDataProfileImportExportService.ts index a45bd6dc1aff2..98c6cefbe2cb3 100644 --- a/src/vs/workbench/services/userDataProfile/browser/userDataProfileImportExportService.ts +++ b/src/vs/workbench/services/userDataProfile/browser/userDataProfileImportExportService.ts @@ -755,6 +755,7 @@ class UserDataProfileExportState extends UserDataProfileImportExportState { promptsHome: profile.promptsHome.with({ scheme: USER_DATA_PROFILE_EXPORT_SCHEME }), extensionsResource: profile.extensionsResource, cacheHome: profile.cacheHome, + agentPluginsHome: profile.agentPluginsHome, useDefaultFlags: profile.useDefaultFlags, isTransient: profile.isTransient }; diff --git a/src/vs/workbench/services/workingCopy/test/electron-browser/workingCopyBackupService.test.ts b/src/vs/workbench/services/workingCopy/test/electron-browser/workingCopyBackupService.test.ts index 710844b0653b0..7420d5e546df8 100644 --- a/src/vs/workbench/services/workingCopy/test/electron-browser/workingCopyBackupService.test.ts +++ b/src/vs/workbench/services/workingCopy/test/electron-browser/workingCopyBackupService.test.ts @@ -51,7 +51,8 @@ const NULL_PROFILE = { snippetsHome: joinPath(homeDir, 'snippets'), promptsHome: joinPath(homeDir, 'prompts'), extensionsResource: joinPath(homeDir, 'extensions.json'), - cacheHome: joinPath(homeDir, 'cache') + cacheHome: joinPath(homeDir, 'cache'), + agentPluginsHome: joinPath(homeDir, 'agentPluginsHome'), }; const TestNativeWindowConfiguration: INativeWindowConfiguration = { diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index fc12969f6bac6..b46ec3015d55e 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -752,6 +752,11 @@ declare module 'vscode' { * Represents the current state of user inputs for a chat session. */ export interface ChatSessionInputState { + /** + * Fired when the input state is disposed. + */ + readonly onDidDispose: Event; + /** * Fired when the input state is changed by the user. * diff --git a/test/sanity/src/uiTest.ts b/test/sanity/src/uiTest.ts index c2e3e56f6853b..bdad4473840d1 100644 --- a/test/sanity/src/uiTest.ts +++ b/test/sanity/src/uiTest.ts @@ -76,15 +76,15 @@ export class UITest { */ public async dismissWelcomeDialog(page: Page) { this.context.log('Dismissing welcome dialog (if shown)'); - const skipButton = page.getByRole('button', { name: 'Skip' }); + const closeButton = page.locator('button.onboarding-a-close-btn'); try { - await skipButton.waitFor({ state: 'visible', timeout: 5_000 }); + await closeButton.waitFor({ state: 'visible', timeout: 5_000 }); } catch { this.context.log('Welcome dialog not shown, continuing'); return; } - await skipButton.click(); - await skipButton.waitFor({ state: 'hidden' }); + await closeButton.click(); + await closeButton.waitFor({ state: 'hidden' }); } /**