diff --git a/.github/workflows/pr-linux-cli-test.yml b/.github/workflows/pr-linux-cli-test.yml index 78d4c4acdc3a4..e5c5dcd973e69 100644 --- a/.github/workflows/pr-linux-cli-test.yml +++ b/.github/workflows/pr-linux-cli-test.yml @@ -11,7 +11,7 @@ on: jobs: linux-cli-test: name: ${{ inputs.job_name }} - runs-on: ubuntu-22.04 + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=linux-cli-test-${{ inputs.job_name }}-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] env: RUSTUP_TOOLCHAIN: ${{ inputs.rustup_toolchain }} steps: diff --git a/.github/workflows/pr-node-modules.yml b/.github/workflows/pr-node-modules.yml index 952938c0df4cf..731259eb9623d 100644 --- a/.github/workflows/pr-node-modules.yml +++ b/.github/workflows/pr-node-modules.yml @@ -10,7 +10,7 @@ permissions: {} jobs: compile: name: Compile - runs-on: ubuntu-22.04 + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=compile-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] steps: - name: Checkout microsoft/vscode uses: actions/checkout@v6 @@ -86,7 +86,7 @@ jobs: linux: name: Linux - runs-on: ubuntu-22.04 + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=linux-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] env: NPM_ARCH: x64 VSCODE_ARCH: x64 @@ -219,7 +219,7 @@ jobs: windows: name: Windows - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-windows-2022-x64 ] + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-windows-2022-x64, "JobId=windows-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] env: NPM_ARCH: x64 VSCODE_ARCH: x64 diff --git a/.github/workflows/pr-win32-test.yml b/.github/workflows/pr-win32-test.yml index 7a46a9a48bdad..eb3668d88ae63 100644 --- a/.github/workflows/pr-win32-test.yml +++ b/.github/workflows/pr-win32-test.yml @@ -17,7 +17,7 @@ on: jobs: windows-test: name: ${{ inputs.job_name }} - runs-on: windows-2022 + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-windows-2022-x64, "JobId=windows-test-${{ inputs.job_name }}-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] env: ARTIFACT_NAME: ${{ (inputs.electron_tests && 'electron') || (inputs.browser_tests && 'browser') || (inputs.remote_tests && 'remote') || 'unknown' }} NPM_ARCH: x64 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 70c65ddc42662..e97e099119fd1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -19,7 +19,7 @@ env: jobs: compile: name: Compile & Hygiene - runs-on: ubuntu-22.04 + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=compile-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] steps: - name: Checkout microsoft/vscode uses: actions/checkout@v6 @@ -159,7 +159,7 @@ jobs: copilot-check-test-cache: name: Copilot - Check Test Cache - runs-on: ubuntu-22.04 + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=copilot-check-test-cache-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] permissions: contents: read pull-requests: read @@ -205,7 +205,7 @@ jobs: copilot-check-telemetry: name: Copilot - Check Telemetry - runs-on: ubuntu-22.04 + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=copilot-check-telemetry-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] permissions: contents: read steps: @@ -224,7 +224,7 @@ jobs: copilot-linux-tests: name: Copilot - Test (Linux) - runs-on: ubuntu-22.04 + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=copilot-linux-tests-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] permissions: contents: read steps: @@ -329,7 +329,7 @@ jobs: copilot-windows-tests: name: Copilot - Test (Windows) - runs-on: windows-2022 + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-windows-2022-x64, "JobId=copilot-windows-tests-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] permissions: contents: read steps: diff --git a/.vscode/settings.json b/.vscode/settings.json index ed355128faaf9..f998097e8075a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,6 @@ // --- Chat --- "inlineChat.enableV2": true, "inlineChat.affordance": "editor", - "inlineChat.renderMode": "hover", "chat.tools.terminal.autoApprove": { "scripts/test.bat": true, "scripts/test.sh": true, diff --git a/extensions/copilot/assets/prompts/skills/agent-customization/SKILL.md b/extensions/copilot/assets/prompts/skills/agent-customization/SKILL.md index f1f0179aa5fd1..6c2dd839f36c0 100644 --- a/extensions/copilot/assets/prompts/skills/agent-customization/SKILL.md +++ b/extensions/copilot/assets/prompts/skills/agent-customization/SKILL.md @@ -72,7 +72,7 @@ After creating: **Skill vs Custom Agent?** Same capabilities for all steps → Skill. Need context isolation (subagent returns single output) or different tool restrictions per stage → Custom Agent. -**Hooks vs Instructions?** Instructions *guide* agent behavior (non-deterministic). Hooks *enforce* behavior via shell commands at lifecycle events like `PreToolUse` or `PostToolUse` — they can block operations, require approval, or run formatters deterministically. See [hooks reference](./references/hooks.md). +**Hooks vs Instructions?** Instructions *guide* agent behavior (non-deterministic). Hooks *enforce* behavior via shell commands at lifecycle events like `PreToolUse` or `PostToolUse` — they can block operations, require approval, or run formatters deterministically. Hooks can be defined in standalone `.json` files (see [hooks reference](./references/hooks.md)) or inline in custom agent frontmatter via the `hooks` attribute (see [agents reference](./references/agents.md)). ## Common Pitfalls diff --git a/extensions/copilot/assets/prompts/skills/agent-customization/references/agents.md b/extensions/copilot/assets/prompts/skills/agent-customization/references/agents.md index 3721b3b69227e..586419deb461a 100644 --- a/extensions/copilot/assets/prompts/skills/agent-customization/references/agents.md +++ b/extensions/copilot/assets/prompts/skills/agent-customization/references/agents.md @@ -22,6 +22,13 @@ agents: [agent1, agent2] # Optional, restrict allowed subagents by name (omi user-invocable: true # Optional, show in agent picker (default: true) disable-model-invocation: false # Optional, prevent subagent invocation (default: false) handoffs: [...] # Optional, transitions to other agents +hooks: # Optional, inline hooks for this agent's lifecycle events + PreToolUse: + - type: command + command: "./scripts/validate.sh" + PostToolUse: + - type: command + command: "./scripts/format.sh" --- ``` @@ -108,4 +115,31 @@ You are a specialist at {specific task}. Your job is to {clear purpose}. - **Swiss-army agents**: Too many tools, tries to do everything - **Vague descriptions**: "A helpful agent" doesn't guide delegation—be specific - **Role confusion**: Description doesn't match body persona -- **Circular handoffs**: A → B → A without progress criteria \ No newline at end of file +- **Circular handoffs**: A → B → A without progress criteria + +## Inline Hooks + +Custom agents support inline `hooks` in frontmatter. These hooks execute shell commands at agent lifecycle points and are scoped to this agent only. The format matches standalone hook files (see [hooks reference](../hooks.md)). + +### Supported Events + +`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, `SubagentStart`, `SubagentStop`, `Stop` + +### Example + +```yaml +--- +description: "Secure code reviewer that blocks dangerous commands" +tools: [read, search, execute] +hooks: + PreToolUse: + - type: command + command: "./scripts/block-dangerous-cmds.sh" + timeout: 10 + PostToolUse: + - type: command + command: "./scripts/auto-lint.sh" +--- +``` + +Each hook command supports: `type` (must be `command`), `command`, platform overrides (`windows`, `linux`, `osx`), `cwd`, `env`, `timeout`. diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 7971447f3142c..b51aba656a87f 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -3776,6 +3776,15 @@ "onExp" ] }, + "github.copilot.chat.responsesApi.toolSearchTool.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%github.copilot.config.responsesApi.toolSearchTool.enabled%", + "tags": [ + "experimental", + "onExp" + ] + }, "github.copilot.chat.updated53CodexPrompt.enabled": { "type": "boolean", "default": true, diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index a31bb0977fdf7..c20b73d95bd43 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -344,6 +344,7 @@ "github.copilot.config.responsesApiReasoningSummary": "Sets the reasoning summary style used for the Responses API. Requires `#github.copilot.chat.useResponsesApi#`.", "github.copilot.config.responsesApiContextManagement.enabled": "Enables context management for the Responses API. Requires `#github.copilot.chat.useResponsesApi#`.", "github.copilot.config.responsesApi.promptCacheKey.enabled": "Enables prompt cache key being set for the Responses API.", + "github.copilot.config.responsesApi.toolSearchTool.enabled": "Enable tool search for OpenAI Responses API models. When enabled, tools are dynamically discovered and loaded on-demand using embeddings-based search, reducing context window usage when many tools are available.", "github.copilot.config.updated53CodexPrompt.enabled": "Enables the updated prompt for gpt-5.3-codex model.", "github.copilot.config.gpt54ConcisePrompt.enabled": "Enables the concise prompt experiment for gpt-5.4 model.", "github.copilot.config.gpt54LargePrompt.enabled": "Enables the large prompt experiment for gpt-5.4 model.", diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 4129b67b595e8..f476606bf274b 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -5,21 +5,19 @@ import type { Attachment, SendOptions, Session, SessionOptions } from '@github/copilot/sdk'; import * as l10n from '@vscode/l10n'; +import * as cp from 'child_process'; import * as crypto from 'crypto'; import type * as vscode from 'vscode'; import type { ChatParticipantToolToken } from 'vscode'; import { IAuthenticationService } from '../../../../platform/authentication/common/authentication'; import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService'; import { ILogService } from '../../../../platform/log/common/logService'; -import { IFetcherService } from '../../../../platform/networking/common/fetcherService'; import { GenAiMetrics } from '../../../../platform/otel/common/genAiMetrics'; import { CopilotChatAttr, GenAiAttr, GenAiOperationName, IOTelService, ISpanHandle, SpanKind, SpanStatusCode, truncateForOTel, resolveWorkspaceOTelMetadata, workspaceMetadataToOTelAttributes } from '../../../../platform/otel/common/index'; import { CapturingToken } from '../../../../platform/requestLogger/common/capturingToken'; import { IRequestLogger, LoggedRequestKind } from '../../../../platform/requestLogger/common/requestLogger'; import { PromptTokenCategory, PromptTokenLabel } from '../../../../platform/tokenizer/node/promptTokenDetails'; -import { IGitService, getGithubRepoIdFromFetchUrl } from '../../../../platform/git/common/gitService'; -import { IGithubRepositoryService, PermissiveAuthRequiredError } from '../../../../platform/github/common/githubService'; -import { MissionControlApiClient, type McEvent } from './missionControlApiClient'; +import { IGitService } from '../../../../platform/git/common/gitService'; import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService'; import { raceCancellation } from '../../../../util/vs/base/common/async'; import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; @@ -55,6 +53,23 @@ export type CopilotCLICommand = 'compact' | 'plan' | 'fleet' | 'remote'; */ export const copilotCLICommands: readonly CopilotCLICommand[] = ['compact', 'plan', 'fleet', 'remote'] as const; +/** Event structure sent to the Mission Control API. */ +interface McEvent { + id: string; + timestamp: string; + parentId: string | null; + type: string; + data: Record; +} + +/** Command structure returned from the Mission Control API. */ +interface McCommand { + id: string; + content: string; + type?: string; + state: string; +} + /** * Shared Mission Control state keyed by SDK session ID. * CopilotCLISession instances are recreated per request, so MC state @@ -62,8 +77,8 @@ export const copilotCLICommands: readonly CopilotCLICommand[] = ['compact', 'pla */ interface McSharedState { mcSessionId: string; - /** HTTP client for the MC session endpoints (handles auth, URL, and fetcher routing). */ - mcApiClient: MissionControlApiClient; + mcApiUrl: string; + mcGithubToken: string; mcEventBuffer: McEvent[]; mcCompletedCommandIds: string[]; mcFlushInterval: ReturnType | undefined; @@ -78,30 +93,6 @@ interface McSharedState { } const mcStateBySessionId = new Map(); -/** - * Stop intervals, detach the persistent event listener, and clear sensitive - * fields on a Mission Control shared state. Safe to call multiple times. - */ -function cleanupMcSharedState(state: McSharedState): void { - if (state.mcFlushInterval) { - clearInterval(state.mcFlushInterval); - state.mcFlushInterval = undefined; - } - if (state.mcPollInterval) { - clearInterval(state.mcPollInterval); - state.mcPollInterval = undefined; - } - if (state.mcEventListenerDispose) { - state.mcEventListenerDispose(); - state.mcEventListenerDispose = undefined; - } - // Release buffered events for GC. The API client captures no session-scoped - // credentials — tokens are fetched per-request — so there is nothing further - // to clear. - state.mcEventBuffer = []; - state.mcCompletedCommandIds = []; -} - export const builtinSlashSCommands = { commit: '/commit', sync: '/sync', @@ -227,8 +218,6 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes @IOTelService private readonly _otelService: IOTelService, @IGitService private readonly _gitService: IGitService, @IAuthenticationService private readonly _authenticationService: IAuthenticationService, - @IGithubRepositoryService private readonly _githubRepositoryService: IGithubRepositoryService, - @IFetcherService private readonly _fetcherService: IFetcherService, ) { super(); this.sessionId = _sdkSession.sessionId; @@ -500,6 +489,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes const shouldHandleExitPlanModeRequests = this.configurationService.getConfig(ConfigKey.Advanced.CLIPlanExitModeEnabled); disposables.add(toDisposable(this._sdkSession.on('*', (event) => { this.logService.trace(`[CopilotCLISession] CopilotCLI Event: ${JSON.stringify(event, null, 2)}`); + this.logService.info(`[CopilotCLISession] on(*) fired: ${event.type}`); // Forward events to Mission Control if remote control is active this._bufferMcEvent(event); }))); @@ -941,18 +931,6 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes this._stream?.progress(l10n.t('Compacting conversation...')); await this._sdkSession.initializeAndValidateTools(); this._sdkSession.currentMode = 'interactive'; - // Mirror the Copilot CLI SDK's own `messages.length < 2` guard to - // avoid its "Nothing to compact." throw, while distinguishing - // empty sessions from already-compacted sessions in the UI. - const messages = await this._sdkSession.getChatMessages(); - if (messages.length === 0) { - this._stream?.markdown(l10n.t('Nothing to compact.')); - break; - } - if (messages.length < 2) { - this._stream?.markdown(l10n.t('Conversation already compacted.')); - break; - } const result = await this._sdkSession.compactHistory(); if (result.success) { this._stream?.markdown(l10n.t('Compacted conversation.')); @@ -1034,9 +1012,22 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes this._stream?.progress(l10n.t('Enabling remote control...')); - // Step 1: Resolve git context (owner/repo). Do this before any auth - // work so we can fail fast on non-GitHub workspaces without prompting - // the user for permissive GitHub scopes unnecessarily. + // Step 1: Get GitHub token + const session = await this._authenticationService.getGitHubSession('any', { silent: true }); + if (!session?.accessToken) { + this._stream?.markdown(l10n.t('Unable to enable remote control: no GitHub authentication available.')); + return; + } + const githubToken = session.accessToken; + + // Step 2: Exchange GitHub token for Copilot API URL + const apiUrl = await this._getCopilotApiUrl(githubToken); + if (!apiUrl) { + this._stream?.markdown(l10n.t('Unable to enable remote control: could not resolve Copilot API.')); + return; + } + + // Step 3: Resolve git context (owner/repo) const workingDir = getWorkingDirectory(this._workspaceInfo); if (!workingDir) { this._stream?.markdown(l10n.t('Unable to enable remote control: no workspace folder found.')); @@ -1049,51 +1040,51 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes return; } - // Step 2: Resolve numeric owner/repo IDs via GitHub API. Routed - // through `IGithubRepositoryService` so it hits the correct API host - // (github.com or a GHES instance) with consistent proxy/telemetry. - let repoData: { id: number; owner: { id: number } }; - try { - repoData = await this._githubRepositoryService.getRepositoryInfo(nwo.owner, nwo.repo); - } catch (err) { - this.logService.warn(`[CopilotCLISession] Failed to resolve repository ${nwo.owner}/${nwo.repo}: ${err}`); + // Step 4: Resolve numeric owner/repo IDs via GitHub API + const repoResponse = await fetch(`https://api.github.com/repos/${nwo.owner}/${nwo.repo}`, { + headers: { 'Authorization': `token ${githubToken}`, 'Accept': 'application/json' }, + }); + if (!repoResponse.ok) { this._stream?.markdown(l10n.t('Unable to enable remote control: could not resolve repository {0}/{1}.', nwo.owner, nwo.repo)); return; } + const repoData = await repoResponse.json() as { id: number; owner: { id: number } }; - // Step 3: Create the Mission Control session through the dedicated - // API client. The client handles permissive auth acquisition (with - // an interactive sign-in prompt), GHES-aware URL resolution, and - // `IFetcherService` routing for proxy/CA/telemetry correctness. - const mcApiClient = new MissionControlApiClient(this._authenticationService, this._fetcherService, this.logService); + // Step 5: Create Mission Control session const agentTaskId = `${Date.now()}-${Math.random().toString(36).substring(2, 10)}`; - let createResult: { id: string; taskId: string }; - try { - createResult = await mcApiClient.createSession(repoData.owner.id, repoData.id, agentTaskId, { - createIfNone: { detail: l10n.t('Sign in to GitHub to enable remote control for this session.') }, - }); - } catch (err) { - if (err instanceof PermissiveAuthRequiredError) { - this._stream?.markdown(l10n.t('Unable to enable remote control: additional GitHub permissions are required. Please sign in again to grant access.')); - return; - } - this.logService.error(`[CopilotCLISession] MC session creation failed: ${err}`); - this._stream?.markdown(l10n.t('Unable to enable remote control: {0}', err instanceof Error ? err.message : String(err))); + const mcUrl = `${apiUrl}/agents/sessions`; + this.logService.info(`[CopilotCLISession] Creating MC session at ${mcUrl}`); + + const mcResponse = await fetch(mcUrl, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${githubToken}`, + 'Content-Type': 'application/json', + 'Copilot-Integration-Id': 'copilot-developer-cli', + }, + body: JSON.stringify({ + owner_id: repoData.owner.id, + repo_id: repoData.id, + agent_task_id: agentTaskId, + }), + }); + + if (!mcResponse.ok) { + const body = await mcResponse.text().catch(() => ''); + this.logService.error(`[CopilotCLISession] MC session creation failed: ${mcResponse.status} ${mcResponse.statusText} - ${body}`); + this._stream?.markdown(l10n.t('Unable to enable remote control: session creation failed ({0}).', `${mcResponse.status}`)); return; } - // Step 4: Store MC state in the shared map (keyed by SDK session ID) - // so it persists across CopilotCLISession instances. If a prior MC - // state exists (e.g. /remote invoked twice, or re-enabled after a - // partial failure), tear down its listeners/intervals before replacing - // it to avoid orphaned setInterval tasks and on('*') handlers. - const existingSharedState = mcStateBySessionId.get(this.sessionId); - if (existingSharedState) { - cleanupMcSharedState(existingSharedState); - } + const mcData = await mcResponse.json() as { id: string; task_id?: string }; + const taskId = mcData.task_id ?? agentTaskId; + + // Step 6: Store MC state in the shared map (keyed by SDK session ID) + // so it persists across CopilotCLISession instances. const sharedState: McSharedState = { - mcSessionId: createResult.id, - mcApiClient, + mcSessionId: mcData.id, + mcApiUrl: apiUrl, + mcGithubToken: githubToken, mcEventBuffer: [], mcCompletedCommandIds: [], mcFlushInterval: undefined, @@ -1104,26 +1095,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes mcSessionResource: SessionIdForCLI.getResource(this.sessionId), }; mcStateBySessionId.set(this.sessionId, sharedState); - this.logService.info(`[CopilotCLISession] Set shared MC state for session ${this.sessionId}, mcSessionId=${createResult.id}`); - - // Tie shared-state cleanup to the SDK session's lifecycle. If the - // session ends (or errors out) without an explicit `/remote off`, - // we must still stop intervals, detach the persistent listener, - // and drop the cached GitHub token — otherwise the module-level - // map and background timers outlive the session. - const cleanupSessionIdCapture = this.sessionId; - const cleanupLogService = this.logService; - const cleanupOnShutdown = () => { - const state = mcStateBySessionId.get(cleanupSessionIdCapture); - if (!state || state.mcSdkSession !== sharedState.mcSdkSession) { - return; - } - cleanupLogService.info(`[CopilotCLISession] SDK session ended — cleaning up MC state for ${cleanupSessionIdCapture}`); - cleanupMcSharedState(state); - mcStateBySessionId.delete(cleanupSessionIdCapture); - }; - const disposeOnSessionShutdown = this._sdkSession.on('session.shutdown', cleanupOnShutdown); - const disposeOnSessionError = this._sdkSession.on('session.error', cleanupOnShutdown); + this.logService.info(`[CopilotCLISession] Set shared MC state for session ${this.sessionId}, mcSessionId=${mcData.id}`); // Step 7: Send the initial session.start event — MC requires this to // transition out of "Fueling the runtime engines..." loading state. @@ -1175,7 +1147,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes // are captured and forwarded to MC. Per-request listeners are disposed // after each request completes, so this persistent listener fills the gap. const sessionId = this.sessionId; - const disposePersistentEventListener = this._sdkSession.on('*', (event) => { + sharedState.mcEventListenerDispose = this._sdkSession.on('*', (event) => { const state = mcStateBySessionId.get(sessionId); if (!state) { return; } // Use the static helper instead of this._bufferMcEvent to avoid @@ -1221,19 +1193,8 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes } }); - // Combine all three SDK listener disposers so `cleanupMcSharedState` - // (via `mcEventListenerDispose`) tears them all down in one step — - // on `/remote off`, SDK session shutdown/error, or replacement. - sharedState.mcEventListenerDispose = () => { - disposePersistentEventListener(); - disposeOnSessionShutdown(); - disposeOnSessionError(); - }; - - // Step 8: Construct and display the frontend URL. Use the host from - // the resolved repo so GHES/GHE.com repositories open on the correct - // domain rather than always linking to github.com. - const frontendUrl = `https://${nwo.host}/${nwo.owner}/${nwo.repo}/tasks/${createResult.taskId}`; + // Step 8: Construct and display the frontend URL + const frontendUrl = `https://github.com/${nwo.owner}/${nwo.repo}/tasks/${taskId}`; this.logService.info(`[CopilotCLISession] MC session created, URL: ${frontendUrl}`); // Render a persistent inline info banner using the proposed @@ -1242,7 +1203,8 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes // `vscode.open` so it opens the URL externally without invoking // the model, and the banner stays visible after click. const banner = new MarkdownString( - l10n.t('**Remote control is enabled.** You can open this session from any device.') + `**${l10n.t('Remote control is enabled.')}** ` + + l10n.t('You can open this session from any device.') ); this._stream?.info(banner); this._stream?.button({ @@ -1264,42 +1226,82 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes * Tear down an active Mission Control session. */ private async _teardownRemoteControl(): Promise { + // Stop exporter and poller + this._stopMcEventExporter(); + this._stopMcCommandPoller(); + const state = this._mcState; if (!state) { this.logService.info('[CopilotCLISession] No active MC session to tear down'); return; } + // Clean up the persistent event listener + if (state.mcEventListenerDispose) { + state.mcEventListenerDispose(); + state.mcEventListenerDispose = undefined; + } + const mcSessionId = state.mcSessionId; - const mcApiClient = state.mcApiClient; - cleanupMcSharedState(state); mcStateBySessionId.delete(this.sessionId); this.logService.info(`[CopilotCLISession] Tearing down MC session ${mcSessionId}`); - // Best-effort server-side teardown; the client swallows its own errors. - await mcApiClient.deleteSession(mcSessionId); + try { + const session = await this._authenticationService.getGitHubSession('any', { silent: true }); + if (!session?.accessToken) { + return; + } + const apiUrl = await this._getCopilotApiUrl(session.accessToken); + if (apiUrl) { + await fetch(`${apiUrl}/agents/sessions/${mcSessionId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${session.accessToken}`, + 'Copilot-Integration-Id': 'copilot-developer-cli', + }, + }); + } + } catch { + this.logService.warn(`[CopilotCLISession] Failed to tear down MC session ${mcSessionId}`); + } } /** - * Resolve owner/repo for a working directory using `IGitService`, which - * handles non-`origin` remotes, SSH aliases, and GitHub Enterprise hosts - * via the shared parsing utilities. + * Exchange a GitHub token for the Copilot API URL. */ - private async _resolveGitHubNwo(workingDirectory: vscode.Uri): Promise<{ owner: string; repo: string; host: string } | undefined> { - const fetchInfo = await this._gitService.getRepositoryFetchUrls(workingDirectory); - if (!fetchInfo?.remoteFetchUrls) { + private async _getCopilotApiUrl(githubToken: string): Promise { + const tokenResponse = await fetch('https://api.github.com/copilot_internal/v2/token', { + headers: { + 'Authorization': `token ${githubToken}`, + 'Accept': 'application/json', + }, + }); + if (!tokenResponse.ok) { return undefined; } - for (const fetchUrl of fetchInfo.remoteFetchUrls) { - if (!fetchUrl) { - continue; - } - const repoId = getGithubRepoIdFromFetchUrl(fetchUrl); - if (repoId) { - return { owner: repoId.org, repo: repoId.repo, host: repoId.host }; - } - } - return undefined; + const tokenData = await tokenResponse.json() as { token: string; endpoints?: { api?: string } }; + return tokenData.endpoints?.api; + } + + /** + * Parse owner/repo from the git remote URL of a working directory. + */ + private _resolveGitHubNwo(workingDirectory: vscode.Uri): Promise<{ owner: string; repo: string } | undefined> { + return new Promise((resolve) => { + cp.execFile('git', ['remote', 'get-url', 'origin'], { cwd: workingDirectory.fsPath, timeout: 5000 }, (_error, stdout) => { + if (!stdout) { + resolve(undefined); + return; + } + const url = stdout.trim(); + const match = url.match(/github\.com[:/](?[^/]+)\/(?[^/]+?)(?:\.git)?$/); + if (match?.groups) { + resolve({ owner: match.groups.owner, repo: match.groups.repo }); + } else { + resolve(undefined); + } + }); + }); } // ── Mission Control event exporter ─────────────────────────────────── @@ -1346,11 +1348,6 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes if (!state) { return; } - // If a persistent MC listener is active, it already buffers every - // SDK event — skip here to avoid duplicating events in the buffer. - if (state.mcEventListenerDispose) { - return; - } // Skip events that should not be forwarded to MC if ( eventType === 'assistant.message_delta' || @@ -1369,7 +1366,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes ) { return; } - this.logService.trace(`[CopilotCLISession] MC buffered event: ${eventType}`); + this.logService.info(`[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. @@ -1410,14 +1407,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes */ private async _flushMcEvents(): Promise { const state = this._mcState; - if (!state || !state.mcSessionId) { - return; - } - // Flush when there is anything to send — either new events, or - // completed command IDs that need to be acknowledged. Returning - // early on empty events would strand acks and cause MC to keep - // re-delivering the same in-progress commands. - if (state.mcEventBuffer.length === 0 && state.mcCompletedCommandIds.length === 0) { + if (!state || !state.mcSessionId || !state.mcApiUrl || !state.mcGithubToken || state.mcEventBuffer.length === 0) { return; } @@ -1427,22 +1417,36 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes const eventTypes = events.map(e => e.type).join(', '); this.logService.info(`[CopilotCLISession] Flushing ${events.length} MC event(s): [${eventTypes}]`); - const ok = await state.mcApiClient.submitEvents(state.mcSessionId, events, completedCommandIds); - if (ok) { - return; - } + try { + const url = `${state.mcApiUrl}/agents/sessions/${state.mcSessionId}/events`; + const body = JSON.stringify({ + events, + completed_command_ids: completedCommandIds.length > 0 ? completedCommandIds : undefined, + }); + this.logService.info(`[CopilotCLISession] POST ${url} (${body.length} bytes)`); + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${state.mcGithubToken}`, + 'Content-Type': 'application/json', + 'Copilot-Integration-Id': 'copilot-developer-cli', + }, + body, + }); - // Re-queue events and completed command IDs on failure so the next attempt - // retries them. Trim after re-queueing so a persistently failing endpoint - // cannot grow the buffers beyond the intended cap. - const MAX_BUFFER = 2000; - state.mcEventBuffer.unshift(...events); - if (state.mcEventBuffer.length > MAX_BUFFER) { - state.mcEventBuffer.splice(0, state.mcEventBuffer.length - MAX_BUFFER); - } - state.mcCompletedCommandIds.unshift(...completedCommandIds); - if (state.mcCompletedCommandIds.length > MAX_BUFFER) { - state.mcCompletedCommandIds.splice(0, state.mcCompletedCommandIds.length - MAX_BUFFER); + if (!response.ok) { + const respBody = await response.text().catch(() => ''); + this.logService.warn(`[CopilotCLISession] MC event submission failed: ${response.status} ${response.statusText} - ${respBody}`); + // Re-queue events on failure (but don't grow unbounded) + if (state.mcEventBuffer.length < 2000) { + state.mcEventBuffer.unshift(...events); + } + } else { + this.logService.info(`[CopilotCLISession] MC event flush OK: ${response.status}`); + } + } catch (err) { + this.logService.warn(`[CopilotCLISession] MC event submission error: ${err}`); } } @@ -1464,7 +1468,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes state.mcPollInterval = setInterval(() => { const currentState = mcStateBySessionId.get(sessionId); - if (!currentState || !currentState.mcSessionId) { + if (!currentState || !currentState.mcSessionId || !currentState.mcApiUrl || !currentState.mcGithubToken) { return; } CopilotCLISession._pollMcCommandsStatic(currentState, logService).catch(err => { @@ -1489,38 +1493,54 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes * Static method to avoid capturing a stale `this` reference. */ private static async _pollMcCommandsStatic(state: McSharedState, logService: { info(msg: string): void; warn(msg: string): void }): Promise { - const commands = await state.mcApiClient.getPendingCommands(state.mcSessionId); + try { + const response = await fetch(`${state.mcApiUrl}/agents/sessions/${state.mcSessionId}/commands`, { + headers: { + 'Authorization': `Bearer ${state.mcGithubToken}`, + 'Copilot-Integration-Id': 'copilot-developer-cli', + }, + }); - for (const cmd of commands) { - if (cmd.state !== 'in_progress') { - continue; + if (!response.ok) { + return; } - logService.info(`[CopilotCLISession] Processing MC command: ${cmd.type ?? 'user_message'} (${cmd.id})`); - switch (cmd.type) { - case 'abort': - state.mcSdkSession.abort(); - break; - case 'user_message': - default: { - // 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'); - vsCodeApi.commands.executeCommand( - 'workbench.action.chat.openSessionWithPrompt.copilotcli', - { - resource: state.mcSessionResource, - prompt: cmd.content, - } - ).then(undefined, err => { - logService.warn(`[CopilotCLISession] MC steering send failed: ${err}`); - }); - break; + const data = await response.json() as { commands?: McCommand[] }; + const commands = data.commands ?? []; + + for (const cmd of commands) { + if (cmd.state !== 'in_progress') { + continue; } - } + logService.info(`[CopilotCLISession] Processing MC command: ${cmd.type ?? 'user_message'} (${cmd.id})`); - // Mark command as processed - state.mcCompletedCommandIds.push(cmd.id); + switch (cmd.type) { + case 'abort': + state.mcSdkSession.abort(); + break; + case 'user_message': + default: { + // 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'); + vsCodeApi.commands.executeCommand( + 'workbench.action.chat.openSessionWithPrompt.copilotcli', + { + resource: state.mcSessionResource, + prompt: cmd.content, + } + ).then(undefined, err => { + logService.warn(`[CopilotCLISession] MC steering send failed: ${err}`); + }); + break; + } + } + + // Mark command as processed + state.mcCompletedCommandIds.push(cmd.id); + } + } catch { + // Silently ignore polling errors } } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts index 1a3ac8f44aced..66b87def8c573 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts @@ -15,8 +15,6 @@ import { NullChatDebugFileLoggerService } from '../../../../../platform/chat/com import { IConfigurationService } from '../../../../../platform/configuration/common/configurationService'; import { NullNativeEnvService } from '../../../../../platform/env/common/nullEnvService'; import { MockFileSystemService } from '../../../../../platform/filesystem/node/test/mockFileSystemService'; -import { IGithubRepositoryService } from '../../../../../platform/github/common/githubService'; -import { IFetcherService } from '../../../../../platform/networking/common/fetcherService'; import { MockGitService } from '../../../../../platform/ignore/node/test/mockGitService'; import { ILogService } from '../../../../../platform/log/common/logService'; import { NullMcpService } from '../../../../../platform/mcp/common/mcpService'; @@ -47,7 +45,7 @@ import { IQuestion, IQuestionAnswer, IUserQuestionHandler } from '../userInputHe import { MockCliSdkSession, MockCliSdkSessionManager, MockSkillLocations, NullCopilotCLIAgents, NullCopilotCLIModels, NullICopilotCLIImageSupport } from './testHelpers'; // Re-export for backward compatibility with other spec files -export { MockCliSdkSession, MockCliSdkSessionManager, MockSkillLocations, NullCopilotCLIAgents, NullCopilotCLIModels, NullICopilotCLIImageSupport } from './testHelpers'; +export { MockCliSdkSession, MockCliSdkSessionManager, MockSkillLocations, NullCopilotCLIAgents, NullICopilotCLIImageSupport } from './testHelpers'; class MockLocalSession { static async fromEvents(events: readonly { type: string }[]): Promise<{}> { @@ -150,7 +148,7 @@ describe('CopilotCLISessionService', () => { } }(); } - return disposables.add(new CopilotCLISession(workspaceInfo, agentName, sdkSession, [], logService, workspaceService, new MockChatSessionMetadataStore(), instantiationService, new NullRequestLogger(), new NullICopilotCLIImageSupport(), new FakeToolsService(), new FakeUserQuestionHandler(), accessor.get(IConfigurationService), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new MockGitService(), authService, new class extends mock() { }(), new class extends mock() { }())); + return disposables.add(new CopilotCLISession(workspaceInfo, agentName, sdkSession, [], logService, workspaceService, new MockChatSessionMetadataStore(), instantiationService, new NullRequestLogger(), new NullICopilotCLIImageSupport(), new FakeToolsService(), new FakeUserQuestionHandler(), accessor.get(IConfigurationService), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new MockGitService(), { _serviceBrand: undefined } as any)); } } as unknown as IInstantiationService; const configurationService = accessor.get(IConfigurationService); 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 36d5d54ce72ce..b791740b78014 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 @@ -30,10 +30,6 @@ import { PermissionRequest } from '../permissionHelpers'; import { IQuestion, IQuestionAnswer, IUserQuestionHandler } from '../userInputHelpers'; import { NullICopilotCLIImageSupport } from './testHelpers'; import { MockGitService } from '../../../../../platform/ignore/node/test/mockGitService'; -import { MockAuthenticationService } from '../../../../../platform/ignore/node/test/mockAuthenticationService'; -import { IGithubRepositoryService } from '../../../../../platform/github/common/githubService'; -import { IFetcherService } from '../../../../../platform/networking/common/fetcherService'; -import { mock } from '../../../../../util/common/test/simpleMock'; vi.mock('../cliHelpers', async (importOriginal) => ({ ...(await importOriginal()), @@ -124,9 +120,6 @@ class MockSdkSession { async compactHistory() { return { success: true }; } - public chatMessages: Awaited> = [{ role: 'user', content: 'hi' }, { role: 'assistant', content: 'hello' }]; - async getChatMessages() { return this.chatMessages; } - async abort() { } isAbortable(): boolean { return true; } @@ -255,9 +248,7 @@ describe('CopilotCLISession', () => { configurationService, new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new MockGitService(), - new MockAuthenticationService(), - new class extends mock() { }(), - new class extends mock() { }() + { _serviceBrand: undefined } as any )); } @@ -733,36 +724,6 @@ describe('CopilotCLISession', () => { expect(sdkSession.currentMode).toBe('interactive'); expect(stream.output.join('\n')).toContain('Compacted conversation.'); }); - - it('reports already-compacted when no new messages since last compaction (issue #311422)', async () => { - const session = await createSession(); - const stream = new MockChatResponseStream(); - session.attachStream(stream); - // Simulate post-compaction state: only the single summary message remains. - sdkSession.chatMessages = [{ role: 'system', content: 'summary' }]; - let compactCalled = false; - sdkSession.compactHistory = async () => { compactCalled = true; return { success: true }; }; - - await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { command: 'compact', prompt: '' }, [], undefined, authInfo, CancellationToken.None); - - expect(compactCalled).toBe(false); - expect(stream.output.join('\n')).toContain('Conversation already compacted.'); - }); - - it('reports nothing-to-compact on an empty session', async () => { - const session = await createSession(); - const stream = new MockChatResponseStream(); - session.attachStream(stream); - // Simulate a brand-new session with no conversation yet. - sdkSession.chatMessages = []; - let compactCalled = false; - sdkSession.compactHistory = async () => { compactCalled = true; return { success: true }; }; - - await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { command: 'compact', prompt: '' }, [], undefined, authInfo, CancellationToken.None); - - expect(compactCalled).toBe(false); - expect(stream.output.join('\n')).toContain('Nothing to compact.'); - }); }); describe('steering (sending messages to a busy session)', () => { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts index a83848fbae6a5..fc58d58e11e5c 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts @@ -14,8 +14,7 @@ import { NullNativeEnvService } from '../../../../platform/env/common/nullEnvSer import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext'; import { MockFileSystemService } from '../../../../platform/filesystem/node/test/mockFileSystemService'; import { IGitService, RepoContext } from '../../../../platform/git/common/gitService'; -import { IGithubRepositoryService, IOctoKitService } from '../../../../platform/github/common/githubService'; -import { IFetcherService } from '../../../../platform/networking/common/fetcherService'; +import { IOctoKitService } from '../../../../platform/github/common/githubService'; import { ILogService } from '../../../../platform/log/common/logService'; import { NoopOTelService, resolveOTelConfig } from '../../../../platform/otel/common/index'; import { NullRequestLogger } from '../../../../platform/requestLogger/node/nullRequestLogger'; @@ -49,7 +48,6 @@ import { IChatDelegationSummaryService } from '../../copilotcli/common/delegatio import { type CopilotCLIModelInfo, type ICopilotCLIModels, type ICopilotCLISDK } from '../../copilotcli/node/copilotCli'; import { CopilotCLIPromptResolver } from '../../copilotcli/node/copilotcliPromptResolver'; import { CopilotCLISession, CopilotCLISessionInput } from '../../copilotcli/node/copilotcliSession'; -import { MockAuthenticationService } from '../../../../platform/ignore/node/test/mockAuthenticationService'; import { CopilotCLISessionService, CopilotCLISessionWorkspaceTracker, ICopilotCLISessionService } from '../../copilotcli/node/copilotcliSessionService'; import { ICopilotCLIMCPHandler } from '../../copilotcli/node/mcpHandler'; import { MockCliSdkSession, MockCliSdkSessionManager, MockSkillLocations, NullCopilotCLIAgents, NullICopilotCLIImageSupport } from '../../copilotcli/node/test/testHelpers'; @@ -190,7 +188,7 @@ class FakeChatSessionWorktreeCheckpointService extends mock modelId); getDefaultModel = vi.fn(async () => 'base'); @@ -394,7 +392,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { } }(); } - const session = new TestCopilotCLISession(workspaceInfo, agentName, sdkSession, [], logService, workspaceService, new MockChatSessionMetadataStore(), instantiationService, new NullRequestLogger(), new NullICopilotCLIImageSupport(), new FakeToolsService(), new FakeUserQuestionHandler(), accessor.get(IConfigurationService), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new FakeGitService(), new MockAuthenticationService(), new class extends mock() { }(), new class extends mock() { }()); + const session = new TestCopilotCLISession(workspaceInfo, agentName, sdkSession, [], logService, workspaceService, new MockChatSessionMetadataStore(), instantiationService, new NullRequestLogger(), new NullICopilotCLIImageSupport(), new FakeToolsService(), new FakeUserQuestionHandler(), accessor.get(IConfigurationService), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new FakeGitService(), { _serviceBrand: undefined } as any); cliSessions.push(session); return disposables.add(session); } @@ -462,13 +460,11 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { const authInfo = await sdk.getAuthInfo(); expect(cliSessions.length).toBe(0); - const result = await participant.createHandler()(request, context, stream, token); + await participant.createHandler()(request, context, stream, token); expect(cliSessions.length).toBe(1); expect(cliSessions[0].requests.length).toBe(1); expect(cliSessions[0].requests[0]).toEqual({ input: { prompt: 'Say hi' }, attachments: [], model: { model: 'base' }, authInfo, token }); - // Result includes the model used so it can be rendered as a footer detail. - expect(result).toEqual({ details: 'Base' }); }); it('uses worktree workingDirectory when isolation is enabled for a new untitled session', async () => { diff --git a/extensions/copilot/src/extension/inlineChat/node/inlineChatIntent.ts b/extensions/copilot/src/extension/inlineChat/node/inlineChatIntent.ts index f077dba256401..0ac62436d769e 100644 --- a/extensions/copilot/src/extension/inlineChat/node/inlineChatIntent.ts +++ b/extensions/copilot/src/extension/inlineChat/node/inlineChatIntent.ts @@ -314,7 +314,7 @@ class InlineChatEditToolsStrategy implements IInlineChatEditStrategy { assertType(documentContext); const isLargeFile = documentContext.document.lineCount > LARGE_FILE_LINE_THRESHOLD; - const availableTools = await this._getAvailableTools(request, isLargeFile); + const availableTools = await this._getAvailableTools(request, endpoint, isLargeFile); const previousRounds: ICompletedToolCallRound[] = []; let failedEditCount = 0; @@ -546,14 +546,9 @@ class InlineChatEditToolsStrategy implements IInlineChatEditStrategy { return { fetchResult, toolCalls, failedEdits, allCallResults }; } - private async _getAvailableTools(request: vscode.ChatRequest, isLargeFile: boolean): Promise { + private async _getAvailableTools(request: vscode.ChatRequest, model: IChatEndpoint, isLargeFile: boolean): Promise { assertType(request.location2 instanceof ChatRequestEditorData); - // const exitTool = this._toolsService.getTool(INLINE_CHAT_EXIT_TOOL_NAME); - // if (!exitTool) { - // this._logService.error('MISSING inline chat exit tool'); - // throw new Error('Missing inline chat exit tool'); - // } const enabledTools = new Set(InlineChatIntent._EDIT_TOOLS); if (!request.location2.selection.isEmpty) { @@ -572,7 +567,7 @@ class InlineChatEditToolsStrategy implements IInlineChatEditStrategy { ), }; - const agentTools = await this._instantiationService.invokeFunction(getAgentTools, fakeRequest); + const agentTools = await this._instantiationService.invokeFunction(getAgentTools, fakeRequest, model); let editTools = agentTools.filter(tool => enabledTools.has(tool.name)); if (editTools.length === 0) { diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts index 69f4d9951a144..bd6186c9d6965 100644 --- a/extensions/copilot/src/extension/intents/node/agentIntent.ts +++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts @@ -18,7 +18,7 @@ import { IAutomodeService } from '../../../platform/endpoint/node/automodeServic import { IEnvService } from '../../../platform/env/common/envService'; import { ILogService } from '../../../platform/log/common/logService'; import { IEditLogService } from '../../../platform/multiFileEdit/common/editLogService'; -import { isAnthropicContextEditingEnabled } from '../../../platform/networking/common/anthropic'; +import { CUSTOM_TOOL_SEARCH_NAME, isAnthropicContextEditingEnabled } from '../../../platform/networking/common/anthropic'; import { IChatEndpoint } from '../../../platform/networking/common/networking'; import { modelsWithoutResponsesContextManagement } from '../../../platform/networking/common/openai'; import { INotebookService } from '../../../platform/notebook/common/notebookService'; @@ -73,7 +73,7 @@ function isResponsesCompactionContextManagementEnabled(endpoint: IChatEndpoint, && !modelsWithoutResponsesContextManagement.has(endpoint.family); } -export const getAgentTools = async (accessor: ServicesAccessor, request: vscode.ChatRequest) => { +export const getAgentTools = async (accessor: ServicesAccessor, request: vscode.ChatRequest, model?: IChatEndpoint) => { const toolsService = accessor.get(IToolsService); const testService = accessor.get(ITestProvider); const tasksService = accessor.get(ITasksService); @@ -81,7 +81,7 @@ export const getAgentTools = async (accessor: ServicesAccessor, request: vscode. const experimentationService = accessor.get(IExperimentationService); const endpointProvider = accessor.get(IEndpointProvider); const editToolLearningService = accessor.get(IEditToolLearningService); - const model = await endpointProvider.getChatEndpoint(request); + model ??= await endpointProvider.getChatEndpoint(request); const allowTools: Record = {}; @@ -120,6 +120,8 @@ export const getAgentTools = async (accessor: ServicesAccessor, request: vscode. const executionSubagentEnabled = configurationService.getExperimentBasedConfig(ConfigKey.Advanced.ExecutionSubagentToolEnabled, experimentationService); allowTools[ToolName.ExecutionSubagent] = isGptOrAnthropic && executionSubagentEnabled; + allowTools[CUSTOM_TOOL_SEARCH_NAME] = !!model.supportsToolSearch; + if (model.family.includes('grok-code')) { allowTools[ToolName.CoreManageTodoList] = false; } diff --git a/extensions/copilot/src/extension/prompt/vscode-node/requestLoggerImpl.ts b/extensions/copilot/src/extension/prompt/vscode-node/requestLoggerImpl.ts index ad4644c7efeb9..c78f557fb9e8a 100644 --- a/extensions/copilot/src/extension/prompt/vscode-node/requestLoggerImpl.ts +++ b/extensions/copilot/src/extension/prompt/vscode-node/requestLoggerImpl.ts @@ -667,7 +667,15 @@ export class RequestLogger extends AbstractRequestLogger { result.push(`serverRequestId : ${entry.result.serverRequestId}`); } if (entry.chatParams.body?.tools) { - const toolNames = entry.chatParams.body.tools.map(t => isOpenAiFunctionTool(t) ? t.function.name : t.name); + const toolNames = entry.chatParams.body.tools.map(t => { + if (isOpenAiFunctionTool(t)) { + return t.function.name; + } + if ('name' in t) { + return t.name; + } + return t.type; + }); const numToolsString = `(${toolNames.length})`; result.push( `
`, diff --git a/extensions/copilot/src/extension/prompts/node/agent/anthropicPrompts.tsx b/extensions/copilot/src/extension/prompts/node/agent/anthropicPrompts.tsx index 41c2f830739e7..24106659a95e8 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/anthropicPrompts.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/anthropicPrompts.tsx @@ -3,8 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BasePromptElementProps, PromptElement, PromptElementProps, PromptPiece, PromptSizing } from '@vscode/prompt-tsx'; -import type { LanguageModelToolInformation } from 'vscode'; +import { PromptElement, PromptElementProps, PromptPiece, PromptSizing } from '@vscode/prompt-tsx'; import { IConfigurationService } from '../../../../platform/configuration/common/configurationService'; import { isHiddenModelG, modelSupportsToolSearch } from '../../../../platform/endpoint/common/chatModelCapabilities'; import { CUSTOM_TOOL_SEARCH_NAME, isAnthropicContextEditingEnabled } from '../../../../platform/networking/common/anthropic'; @@ -14,6 +13,7 @@ import { IExperimentationService } from '../../../../platform/telemetry/common/n import { agenticBrowserTools, ToolName } from '../../../tools/common/toolNames'; import { InstructionMessage } from '../base/instructionMessage'; import { ResponseTranslationRules } from '../base/responseTranslationRules'; +import { ToolSearchToolPromptOptimized, ToolSearchToolPromptProps } from './toolSearchInstructions'; import { Tag } from '../base/tag'; import { EXISTING_CODE_MARKER } from '../panel/codeBlockFormattingRules'; import { MathIntegrationRules } from '../panel/editorIntegrationRules'; @@ -21,11 +21,6 @@ import { CodesearchModeInstructions, DefaultAgentPromptProps, detectToolCapabili import { FileLinkificationInstructions, FileLinkificationInstructionsOptimized } from './fileLinkificationInstructions'; import { IAgentPrompt, PromptRegistry, ReminderInstructionsConstructor, SystemPrompt } from './promptRegistry'; -interface ToolSearchToolPromptProps extends BasePromptElementProps { - readonly availableTools: readonly LanguageModelToolInformation[] | undefined; - readonly modelFamily: string | undefined; -} - /** * Prompt component that provides instructions for using the tool search tool * to load deferred tools before calling them directly. @@ -318,51 +313,6 @@ class Claude45DefaultPrompt extends PromptElement { } } -/** - * Condensed variant of ToolSearchToolPrompt used by optimized Claude 4.6 prompt configurations. - * Flattens nested tags, removes explanatory text, and drops the custom search variant. - */ -class ToolSearchToolPromptOptimized extends PromptElement { - constructor( - props: PromptElementProps, - @IToolDeferralService private readonly toolDeferralService: IToolDeferralService, - ) { - super(props); - } - - async render(state: void, sizing: PromptSizing) { - const endpoint = sizing.endpoint as IChatEndpoint | undefined; - - const toolSearchEnabled = endpoint - ? !!endpoint.supportsToolSearch - : modelSupportsToolSearch(this.props.modelFamily ?? ''); - - if (!toolSearchEnabled || !this.props.availableTools) { - return; - } - - const deferredTools = this.props.availableTools - .filter(tool => !this.toolDeferralService.isNonDeferredTool(tool.name)) - .map(tool => tool.name) - .sort(); - - if (deferredTools.length === 0) { - return; - } - - return - You MUST use {CUSTOM_TOOL_SEARCH_NAME} to load deferred tools BEFORE calling them. Calling a deferred tool without loading it first will fail.
-
- Describe what capability you need in natural language. The search uses semantic similarity to find the most relevant tools.
-
- Do NOT call {CUSTOM_TOOL_SEARCH_NAME} again for a tool already returned by a previous search. If a search returns no matching tools, the tool is not available. Do not retry with different patterns.
-
- Available deferred tools (must be loaded before use):
- {deferredTools.join('\n')} -
; - } -} - /** * Base class for optimized Claude 4.6 prompt configurations. * Renders the shared base prompt sections from the optimization test plan. diff --git a/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54ConcisePrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54ConcisePrompt.tsx index eb2b00d7d6ad7..d500887ef8344 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54ConcisePrompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54ConcisePrompt.tsx @@ -147,7 +147,7 @@ export class Gpt54ConcisePromptExp extends PromptElement - You use 1-2 sentence user updates to communicated progress and new information to the user as you are doing work.
- Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (“Done —”, “Got it”, “Great question, ”) or framing phrases.
- - You must always start with a intermediary update before any content in the `analysis` channel. The initial message should be a user update acknowledging the request and explaining your first step. You should include your understanding of the user request and explain what you will do. Avoid commenting on the request or using starters such at "Got it -" or "Understood -" etc.
+ - You must always start with an intermediary update before any content in the `analysis` channel if the task will require calling tools. The user update should acknowledge the request and explain your first step. Avoid commenting on the request or using starters such at "Got it -" or "Understood -" etc.
- You provide user updates frequently, every 30s.
- When exploring, e.g. searching, reading files you provide user updates as you go, explaining what context you are gathering and what you've learned. Vary your sentence structure when providing these updates to avoid sounding repetitive - in particular, don't start each sentence the same way.
- When working for a while, keep updates informative and varied, but stay concise.
diff --git a/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54LargePrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54LargePrompt.tsx index 2ede60faa73f6..19f09f105cf23 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54LargePrompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54LargePrompt.tsx @@ -176,7 +176,7 @@ export class Gpt54LargePromptExp extends PromptElement - User updates are short updates while you are working, they are NOT final answers.
- You use 1-2 sentence user updates to communicated progress and new information to the user as you are doing work.
- Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (“Done —”, “Got it”, “Great question, ”) or framing phrases.
- - You must always start with a intermediary update before any content in the `analysis` channel. The initial message should be a user update acknowledging the request and explaining your first step. You should include your understanding of the user request and explain what you will do. Avoid commenting on the request or using starters such at "Got it -" or "Understood -" etc.
+ - You must always start with an intermediary update before any content in the `analysis` channel if the task will require calling tools. The user update should acknowledge the request and explain your first step. Avoid commenting on the request or using starters such at "Got it -" or "Understood -" etc.
- You provide user updates frequently, every 30s.
- When exploring, e.g. searching, reading files you provide user updates as you go, explaining what context you are gathering and what you've learned. Vary your sentence structure when providing these updates to avoid sounding repetitive - in particular, don't start each sentence the same way.
- When working for a while, keep updates informative and varied, but stay concise.
diff --git a/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54Prompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54Prompt.tsx index 7512ef5883208..de48d01ed4b6d 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54Prompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54Prompt.tsx @@ -8,6 +8,7 @@ import { isGpt54, isGpt54ConcisePromptExp, isGpt54LargePromptExp } from '../../. import { IChatEndpoint } from '../../../../../platform/networking/common/networking'; import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation'; import { ToolName } from '../../../../tools/common/toolNames'; +import { CUSTOM_TOOL_SEARCH_NAME, ToolSearchToolPromptOptimized } from '../toolSearchInstructions'; import { GPT5CopilotIdentityRule } from '../../base/copilotIdentity'; import { InstructionMessage } from '../../base/instructionMessage'; import { ResponseTranslationRules } from '../../base/responseTranslationRules'; @@ -79,6 +80,7 @@ export class Gpt54Prompt extends PromptElement { {this.props.availableTools && } + {tools[ToolName.ApplyPatch] && } When doing frontend design tasks, avoid collapsing into "AI slop" or safe, average-looking layouts.
@@ -136,7 +138,7 @@ export class Gpt54Prompt extends PromptElement { - User updates are short updates while you are working, they are NOT final answers.
- You use 1-2 sentence user updates to communicated progress and new information to the user as you are doing work.
- Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (“Done —”, “Got it”, “Great question, ”) or framing phrases.
- - You must always start with a intermediary update before any content in the `analysis` channel. The initial message should be a user update acknowledging the request and explaining your first step. You should include your understanding of the user request and explain what you will do. Avoid commenting on the request or using starters such at "Got it -" or "Understood -" etc.
+ - You must always start with an intermediary update before any content in the `analysis` channel if the task will require calling tools. The user update should acknowledge the request and explain your first step. Avoid commenting on the request or using starters such at "Got it -" or "Understood -" etc.
- You provide user updates frequently, every 30s.
- When exploring, e.g. searching, reading files you provide user updates as you go, explaining what context you are gathering and what you've learned. Vary your sentence structure when providing these updates to avoid sounding repetitive - in particular, don't start each sentence the same way.
- When working for a while, keep updates informative and varied, but stay concise.
@@ -226,6 +228,7 @@ class Gpt54PromptResolver implements IAgentPrompt { export class Gpt54ReminderInstructions extends PromptElement { async render(state: void, sizing: PromptSizing) { + const toolSearchEnabled = !!this.props.endpoint.supportsToolSearch; return <> You are an agent—keep going until the user's query is completely resolved before ending your turn. ONLY stop if solved or genuinely blocked.
Take action when possible; the user expects you to do useful work without unnecessary questions.
@@ -235,6 +238,10 @@ export class Gpt54ReminderInstructions extends PromptElement Requirements coverage: Read the user's ask in full and think carefully. Do not omit a requirement. If something cannot be done with available tools, note why briefly and propose a viable alternative.
{getEditingReminder(this.props.hasEditFileTool, this.props.hasReplaceStringTool, false /* useStrongReplaceStringHint */, this.props.hasMultiReplaceStringTool)} + {toolSearchEnabled && <> +
+ IMPORTANT: Before calling any deferred tool that was not previously returned by {CUSTOM_TOOL_SEARCH_NAME}, you MUST first use {CUSTOM_TOOL_SEARCH_NAME} to load it. Calling a deferred tool without first loading it will fail. Tools returned by {CUSTOM_TOOL_SEARCH_NAME} are automatically expanded and immediately available - do not search for them again.
+ } ; } } diff --git a/extensions/copilot/src/extension/prompts/node/agent/openai/hiddenModelBPrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/openai/hiddenModelBPrompt.tsx index 32b6153491a1c..72f417fad818f 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/openai/hiddenModelBPrompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/openai/hiddenModelBPrompt.tsx @@ -34,6 +34,7 @@ class HiddenModelBPrompt extends PromptElement { You bring a senior engineer’s judgment to the work, but you let it arrive through attention rather than premature certainty. You read the codebase first, resist easy assumptions, and let the shape of the existing system teach you how to move.
- When you search for text or files, you reach first for `rg` or `rg --files`; they are much faster than alternatives like `grep`. If `rg` is unavailable, you use the next best tool without fuss.
- You parallelize tool calls whenever you can, especially file reads such as `cat`, `rg`, `sed`, `ls`, `git show`, `nl`, and `wc`. You use `multi_tool_use.parallel` for that parallelism, and only that. Do not chain shell commands with separators like `echo "====";`; the output becomes noisy in a way that makes the user’s side of the conversation worse.
+ {tools[ToolName.SearchSubagent] && <>- For efficient codebase exploration, prefer {ToolName.SearchSubagent} to search and gather data instead of directly calling {ToolName.FindTextInFiles}, {ToolName.Codebase} or {ToolName.FindFiles}. Use this as a quick injection of context before beginning to solve the problem yourself.
}
When the user leaves implementation details open, you choose conservatively and in sympathy with the codebase already in front of you:
@@ -46,33 +47,33 @@ class HiddenModelBPrompt extends PromptElement { You follow these instructions when building applications with a frontend experience:
- - If working with an existing design or given a design framework in context, you pay careful attention to existing conventions and ensure that what you build is consistent with the frameworks used and design of the existing application.
- - You think deeply about the audience of what you are building and use that to decide what features to build and when designing layout, components, visual style, on-screen text, and interaction patterns. Using your application should feel rich and sophisticated.
- - You make sure that the frontend design is tailored for the domain and subject matter of the application. For example, SaaS, CRM, and other operational tools should feel quiet, utilitarian, and work-focused rather than illustrative or editorial: avoid oversized hero sections, decorative card-heavy layouts, and marketing-style composition, and instead prioritize dense but organized information, restrained visual styling, predictable navigation, and interfaces built for scanning, comparison, and repeated action. A game can be more illustrative, expressive, animated, and playful.
- - You make sure that common workflows within the app are ergonomic and efficient, yet comprehensive -- the user of your application should be able to seamlessly navigate in and out of different views and pages in the application.
+ - If working with an existing design or given a design framework in context, you pay careful attention to existing conventions and ensure that what you build is consistent with the frameworks used and design of the existing application.
+ - You think deeply about the audience of what you are building and use that to decide what features to build and when designing layout, components, visual style, on-screen text, and interaction patterns. Using your application should feel rich and sophisticated.
+ - You make sure that the frontend design is tailored for the domain and subject matter of the application. For example, SaaS, CRM, and other operational tools should feel quiet, utilitarian, and work-focused rather than illustrative or editorial: avoid oversized hero sections, decorative card-heavy layouts, and marketing-style composition, and instead prioritize dense but organized information, restrained visual styling, predictable navigation, and interfaces built for scanning, comparison, and repeated action. A game can be more illustrative, expressive, animated, and playful.
+ - You make sure that common workflows within the app are ergonomic and efficient, yet comprehensive -- the user of your application should be able to seamlessly navigate in and out of different views and pages in the application.
- - You make sure to use icons in buttons for tools, swatches for color, segmented controls for modes, toggles/checkboxes for binary settings, sliders/steppers/inputs for numeric values, menus for option sets, tabs for views, and text or icon+text buttons only for clear commands (unless otherwise specified). Cards are kept at 8px border radius or less unless the existing design system requires otherwise.
- - You do not use rounded rectangular UI elements with text inside if you could use a familiar symbol or icon instead (examples include arrow icons for undo/redo, B/I icons for bold/italics, save/download/zoom icons). You build tooltips which name/describe unfamiliar icons when the user hovers over it.
- - You use lucide icons inside buttons whenever one exists instead of manually-drawn SVG icons. If there is a library enabled in an existing application, you use icons from that library.
- - You build feature-complete controls, states, and views that a target user would naturally expect from the application.
- - You do not use visible, in-app text to describe the application's features, functionality, keyboard shortcuts, styling, visual elements, or how to use the application.
- - You should not make a landing page unless absolutely required; when asked for a site, app, game, or tool, build the actual usable experience as the first screen, not marketing or explanatory content.
- - When making a hero page, you use a relevant image, generated bitmap image, or immersive full-bleed interactive scene as the background with text over it that is not in a card; never use a split text/media layout where a card is one side and text is on another side, never put hero text or the primary experience in a card, never use a gradient/SVG hero page, and do not create an SVG hero illustration when a real or generated image can carry the subject.
- - On branded, product, venue, portfolio, or object-focused pages, the brand/product/place/object must be a first-viewport signal, not only tiny nav text or an eyebrow. Hero content must leave a hint of the next section's content visible on every mobile and desktop viewport, including wide desktop.
- - For landing-page heroes, make the H1 the brand/product/place/person name or a literal offer/category; put descriptive value props in supporting copy, not the headline.
- - Websites and games must use visual assets. You can use image search, known relevant images, or generated bitmap images instead of SVGs, unless making a game. Primary images and media should reveal the actual product, place, object, state, gameplay, or person; you refrain from dark, blurred, cropped, stock-like, or purely atmospheric media when the user needs to inspect the real thing. For highly specific game assets you use custom SVG/Three.js/etc.
- - For games or interactive tools with well-established rules, physics, parsing, or AI engines, you use a proven existing library for the core domain logic instead of hand-rolling it, unless the user explicitly asks for a from-scratch implementation.
- - You use Three.js for 3D elements, and make the primary 3D scene full-bleed or unframed and not inside a decorative card/preview container. Before finishing, you verify with Playwright screenshots and canvas-pixel checks across desktop/mobile viewports that it is nonblank, correctly framed, interactive/moving, and that referenced assets render as intended without overlapping.
- - You do not put UI cards inside other cards. Do not style page sections as floating cards. Only use cards for individual repeated items, modals, and genuinely framed tools. Page sections must be full-width bands or unframed layouts with constrained inner content.
- - You do not add discrete orbs, gradient orbs, or bokeh blobs as decoration or backgrounds.
- - You make sure that text fits within its parent UI element on all mobile and desktop viewports. Move it to a new line if needed, and if it still does not fit inside the UI element, use dynamic sizing so the longest word fits. Text must also not occlude preceding or subsequent content. Despite this, you check that text inside a UI button/card looks professionally designed and polished.
- - Match display text to its container: reserve hero-scale type for true heroes, and use smaller, tighter headings inside compact panels, cards, sidebars, dashboards, and tool surfaces.
- - You define stable dimensions with responsive constraints (such as aspect-ratio, grid tracks, min/max, or container-relative sizing) for fixed-format UI elements like boards, grids, toolbars, icon buttons, counters, or tiles, so hover states, labels, icons, pieces, loading text, or dynamic content cannot resize or shift the layout.
- - You do not scale font size with viewport width. Letter spacing must be 0, not negative.
- - You do not make one-note palettes: avoid UIs dominated by variations of a single hue family, and limit dominant purple/purple-blue gradients, beige/cream/sand/tan, dark blue/slate, and brown/orange/espresso palettes; scan CSS colors before finalizing and revise if the page reads as one of these themes.
- - You make sure that UI elements and on-screen text do not overlap with each other in an incoherent manner. This is extremely important as it leads to a jarring user experience.
- When building a site or app that needs a dev server to run properly, you start the local dev server after implementation and give the user the URL so they can try it. If there's already a server on that port, you use another one. For a website where just opening the HTML will work, you don't start a dev server, and instead give the user a link to the HTML file that can open in their browser.
+ - You make sure to use icons in buttons for tools, swatches for color, segmented controls for modes, toggles/checkboxes for binary settings, sliders/steppers/inputs for numeric values, menus for option sets, tabs for views, and text or icon+text buttons only for clear commands (unless otherwise specified). Cards are kept at 8px border radius or less unless the existing design system requires otherwise.
+ - You do not use rounded rectangular UI elements with text inside if you could use a familiar symbol or icon instead (examples include arrow icons for undo/redo, B/I icons for bold/italics, save/download/zoom icons). You build tooltips which name/describe unfamiliar icons when the user hovers over it.
+ - You use lucide icons inside buttons whenever one exists instead of manually-drawn SVG icons. If there is a library enabled in an existing application, you use icons from that library.
+ - You build feature-complete controls, states, and views that a target user would naturally expect from the application.
+ - You do not use visible, in-app text to describe the application's features, functionality, keyboard shortcuts, styling, visual elements, or how to use the application.
+ - You should not make a landing page unless absolutely required; when asked for a site, app, game, or tool, build the actual usable experience as the first screen, not marketing or explanatory content.
+ - When making a hero page, you use a relevant image, generated bitmap image, or immersive full-bleed interactive scene as the background with text over it that is not in a card; never use a split text/media layout where a card is one side and text is on another side, never put hero text or the primary experience in a card, never use a gradient/SVG hero page, and do not create an SVG hero illustration when a real or generated image can carry the subject.
+ - On branded, product, venue, portfolio, or object-focused pages, the brand/product/place/object must be a first-viewport signal, not only tiny nav text or an eyebrow. Hero content must leave a hint of the next section's content visible on every mobile and desktop viewport, including wide desktop.
+ - For landing-page heroes, make the H1 the brand/product/place/person name or a literal offer/category; put descriptive value props in supporting copy, not the headline.
+ - Websites and games must use visual assets. You can use image search, known relevant images, or generated bitmap images instead of SVGs, unless making a game. Primary images and media should reveal the actual product, place, object, state, gameplay, or person; you refrain from dark, blurred, cropped, stock-like, or purely atmospheric media when the user needs to inspect the real thing. For highly specific game assets you use custom SVG/Three.js/etc.
+ - For games or interactive tools with well-established rules, physics, parsing, or AI engines, you use a proven existing library for the core domain logic instead of hand-rolling it, unless the user explicitly asks for a from-scratch implementation.
+ - You use Three.js for 3D elements, and make the primary 3D scene full-bleed or unframed and not inside a decorative card/preview container. Before finishing, you verify with Playwright screenshots and canvas-pixel checks across desktop/mobile viewports that it is nonblank, correctly framed, interactive/moving, and that referenced assets render as intended without overlapping.
+ - You do not put UI cards inside other cards. Do not style page sections as floating cards. Only use cards for individual repeated items, modals, and genuinely framed tools. Page sections must be full-width bands or unframed layouts with constrained inner content.
+ - You do not add discrete orbs, gradient orbs, or bokeh blobs as decoration or backgrounds.
+ - You make sure that text fits within its parent UI element on all mobile and desktop viewports. Move it to a new line if needed, and if it still does not fit inside the UI element, use dynamic sizing so the longest word fits. Text must also not occlude preceding or subsequent content. Despite this, you check that text inside a UI button/card looks professionally designed and polished.
+ - Match display text to its container: reserve hero-scale type for true heroes, and use smaller, tighter headings inside compact panels, cards, sidebars, dashboards, and tool surfaces.
+ - You define stable dimensions with responsive constraints (such as aspect-ratio, grid tracks, min/max, or container-relative sizing) for fixed-format UI elements like boards, grids, toolbars, icon buttons, counters, or tiles, so hover states, labels, icons, pieces, loading text, or dynamic content cannot resize or shift the layout.
+ - You do not scale font size with viewport width. Letter spacing must be 0, not negative.
+ - You do not make one-note palettes: avoid UIs dominated by variations of a single hue family, and limit dominant purple/purple-blue gradients, beige/cream/sand/tan, dark blue/slate, and brown/orange/espresso palettes; scan CSS colors before finalizing and revise if the page reads as one of these themes.
+ - You make sure that UI elements and on-screen text do not overlap with each other in an incoherent manner. This is extremely important as it leads to a jarring user experience.
+ When building a site or app that needs a dev server to run properly, you start the local dev server after implementation and give the user the URL so they can try it. If there's already a server on that port, you use another one. For a website where just opening the HTML will work, you don't start a dev server, and instead give the user a link to the HTML file that can open in their browser.
@@ -136,7 +137,7 @@ class HiddenModelBPrompt extends PromptElement { - Intermediary updates go to the `commentary` channel.
- User updates are short updates while you are working, they are NOT final answers.
- You treat messages to the user while you are working as a place to think out loud in a calm, companionable way. You casually explain what you are doing and why in one or two sentences.
- - You must always start with a intermediary update before any content in the `analysis` channel if the task will require calling tools. The user update should acknowledge the request and explain your first step.
+ - You must always start with an intermediary update before any content in the `analysis` channel if the task will require calling tools. The user update should acknowledge the request and explain your first step.
- Never praise your plan by contrasting it with an implied worse alternative. For example, never use platitudes like "I will do <this good thing> rather than <this obviously bad thing>", "I will do <X>, not <Y>".
- Never talk about goblins, gremlins, raccoons, trolls, ogres, pigeons, or other animals or creatures unless it is absolutely and unambiguously relevant to the user's query.
- You provide user updates frequently, every 30s.
@@ -154,7 +155,6 @@ class HiddenModelBPrompt extends PromptElement { - Showing user code and tool call details is allowed.

{tools[ToolName.ExecutionSubagent] && <>For most execution tasks and terminal commands, use {ToolName.ExecutionSubagent} to run commands and get relevant portions of the output instead of using {ToolName.CoreRunInTerminal}. Use {ToolName.CoreRunInTerminal} in rare cases when you want the entire output of a single command without truncation.
} - {tools[ToolName.SearchSubagent] && <>- For efficient codebase exploration, prefer {ToolName.SearchSubagent} to search and gather data instead of directly calling {ToolName.FindTextInFiles}, {ToolName.Codebase} or {ToolName.FindFiles}. Use this as a quick injection of context before beginning to solve the problem yourself.
} If completing the user's task requires writing or modifying files, your code and final answer should follow these coding guidelines, though user instructions (i.e. copilot-instructions.md) may override these guidelines:

- Fix the problem at the root cause rather than applying surface-level patches, when possible.
@@ -171,6 +171,10 @@ class HiddenModelBPrompt extends PromptElement { - NEVER output inline citations like "【F:README.md†L5-L14】" in your outputs. The UI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open them in their editor.
- You have access to many tools. If a tool exists to perform a specific task, you MUST use that tool instead of running a terminal command to perform that task.
+ {tools[ToolName.ExecutionSubagent] && <> + + Don't call {ToolName.ExecutionSubagent} multiple times in parallel. Instead, invoke one subagent and wait for its response before running the next command.
+
} ; diff --git a/extensions/copilot/src/extension/prompts/node/agent/toolSearchInstructions.tsx b/extensions/copilot/src/extension/prompts/node/agent/toolSearchInstructions.tsx new file mode 100644 index 0000000000000..7dd5be5f01db8 --- /dev/null +++ b/extensions/copilot/src/extension/prompts/node/agent/toolSearchInstructions.tsx @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BasePromptElementProps, PromptElement, PromptElementProps, PromptSizing } from '@vscode/prompt-tsx'; +import type { LanguageModelToolInformation } from 'vscode'; +import { modelSupportsToolSearch } from '../../../../platform/endpoint/common/chatModelCapabilities'; +import { CUSTOM_TOOL_SEARCH_NAME } from '../../../../platform/networking/common/anthropic'; +import { IToolDeferralService } from '../../../../platform/networking/common/toolDeferralService'; +import { IChatEndpoint } from '../../../../platform/networking/common/networking'; +import { Tag } from '../base/tag'; + +export interface ToolSearchToolPromptProps extends BasePromptElementProps { + readonly availableTools: readonly LanguageModelToolInformation[] | undefined; + readonly modelFamily: string | undefined; +} + +/** + * Condensed tool search instructions shared across model prompts. + * Renders deferred-tool search guidance when the endpoint supports tool search. + * Self-gates on `endpoint.supportsToolSearch` — returns nothing if disabled. + */ +export class ToolSearchToolPromptOptimized extends PromptElement { + constructor( + props: PromptElementProps, + @IToolDeferralService private readonly toolDeferralService: IToolDeferralService, + ) { + super(props); + } + + async render(state: void, sizing: PromptSizing) { + const endpoint = sizing.endpoint as IChatEndpoint | undefined; + + const toolSearchEnabled = endpoint + ? !!endpoint.supportsToolSearch + : modelSupportsToolSearch(this.props.modelFamily ?? ''); + + if (!toolSearchEnabled || !this.props.availableTools) { + return; + } + + const deferredTools = this.props.availableTools + .filter(tool => !this.toolDeferralService.isNonDeferredTool(tool.name)) + .map(tool => tool.name) + .sort(); + + if (deferredTools.length === 0) { + return; + } + + return + You MUST use {CUSTOM_TOOL_SEARCH_NAME} to load deferred tools BEFORE calling them. Calling a deferred tool without loading it first will fail.
+
+ Describe what capability you need in natural language. The search uses semantic similarity to find the most relevant tools.
+
+ Do NOT call {CUSTOM_TOOL_SEARCH_NAME} again for a tool already returned by a previous search. If a search returns no matching tools, the tool is not available. Do not retry with different patterns.
+
+ Available deferred tools (must be loaded before use):
+ {deferredTools.join('\n')} +
; + } +} + +export { CUSTOM_TOOL_SEARCH_NAME }; diff --git a/extensions/copilot/src/extension/tools/node/readFileTool.tsx b/extensions/copilot/src/extension/tools/node/readFileTool.tsx index 28e221e792056..4c98cf8e79bad 100644 --- a/extensions/copilot/src/extension/tools/node/readFileTool.tsx +++ b/extensions/copilot/src/extension/tools/node/readFileTool.tsx @@ -386,19 +386,19 @@ export class ReadFileTool implements ICopilotTool { const plaintextProps = { skillName: skillInfo.skillName, skillPath: uri.toString(), - extensionId, - extensionVersion, + skillExtensionId: extensionId, + skillExtensionVersion: extensionVersion, skillStorage: skillInfo.storage, - contentHash, + skillContentHash: contentHash, }; this.telemetryService.sendGHTelemetryEvent('skillContentRead', { skillNameHash: String(hash(skillInfo.skillName)), - extensionIdHash: extensionId ? String(hash(extensionId)) : '', - extensionVersion: plaintextProps.extensionVersion, + skillExtensionIdHash: extensionId ? String(hash(extensionId)) : '', + skillExtensionVersion: plaintextProps.skillExtensionVersion, skillStorage: plaintextProps.skillStorage, - contentHash, + skillContentHash: contentHash, } ); diff --git a/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx b/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx index 3ddf60b0d7c13..5503a55c18a30 100644 --- a/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx +++ b/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx @@ -765,26 +765,26 @@ suite('ReadFile', () => { expect(event).toBeDefined(); expect(event!.properties!.skillStorage).toBe(SkillStorage.Workspace); expect(event!.properties!.skillNameHash).not.toBe(''); - expect(event!.properties!.extensionIdHash).toBe(''); - expect(event!.properties!.extensionVersion).toBe(''); - expect(event!.properties!.contentHash).not.toBe(''); + expect(event!.properties!.skillExtensionIdHash).toBe(''); + expect(event!.properties!.skillExtensionVersion).toBe(''); + expect(event!.properties!.skillContentHash).not.toBe(''); const enhanced = telemetry.enhancedEvents.find(e => e.eventName === 'skillContentRead'); expect(enhanced).toBeDefined(); expect(enhanced!.properties!.skillName).toBe('my-skill'); expect(enhanced!.properties!.skillPath).toBe(skillUri.toString()); - expect(enhanced!.properties!.extensionId).toBe(''); - expect(enhanced!.properties!.extensionVersion).toBe(''); + expect(enhanced!.properties!.skillExtensionId).toBe(''); + expect(enhanced!.properties!.skillExtensionVersion).toBe(''); expect(enhanced!.properties!.skillStorage).toBe(SkillStorage.Workspace); - expect(enhanced!.properties!.contentHash).not.toBe(''); + expect(enhanced!.properties!.skillContentHash).not.toBe(''); const internal = telemetry.internalEvents.find(e => e.eventName === 'skillContentRead'); expect(internal).toBeDefined(); expect(internal!.properties!.skillName).toBe('my-skill'); expect(internal!.properties!.skillPath).toBe(skillUri.toString()); - expect(internal!.properties!.extensionId).toBe(''); + expect(internal!.properties!.skillExtensionId).toBe(''); expect(internal!.properties!.skillStorage).toBe(SkillStorage.Workspace); - expect(internal!.properties!.contentHash).not.toBe(''); + expect(internal!.properties!.skillContentHash).not.toBe(''); testAccessor.dispose(); }); @@ -821,7 +821,7 @@ suite('ReadFile', () => { testAccessor.dispose(); }); - test('should send skillStorage=extension with extensionIdHash and extensionVersion', async () => { + test('should send skillStorage=extension with skillExtensionIdHash and skillExtensionVersion', async () => { const skillContent = '# Extension Skill'; const skillUri = URI.file('/extensions/publisher.my-ext/skills/ext-skill/SKILL.md'); const testDoc = createTextDocumentData(skillUri, skillContent, 'markdown').document; @@ -861,26 +861,26 @@ suite('ReadFile', () => { const event = telemetry.events.find(e => e.eventName === 'skillContentRead'); expect(event).toBeDefined(); expect(event!.properties!.skillStorage).toBe(SkillStorage.Extension); - expect(event!.properties!.extensionIdHash).not.toBe(''); - expect(event!.properties!.extensionVersion).toBe('1.2.3'); - expect(event!.properties!.contentHash).not.toBe(''); + expect(event!.properties!.skillExtensionIdHash).not.toBe(''); + expect(event!.properties!.skillExtensionVersion).toBe('1.2.3'); + expect(event!.properties!.skillContentHash).not.toBe(''); const enhanced = telemetry.enhancedEvents.find(e => e.eventName === 'skillContentRead'); expect(enhanced).toBeDefined(); expect(enhanced!.properties!.skillName).toBe('ext-skill'); expect(enhanced!.properties!.skillPath).toBe(skillUri.toString()); - expect(enhanced!.properties!.extensionId).toBe('publisher.my-ext'); - expect(enhanced!.properties!.extensionVersion).toBe('1.2.3'); + expect(enhanced!.properties!.skillExtensionId).toBe('publisher.my-ext'); + expect(enhanced!.properties!.skillExtensionVersion).toBe('1.2.3'); expect(enhanced!.properties!.skillStorage).toBe(SkillStorage.Extension); - expect(enhanced!.properties!.contentHash).not.toBe(''); + expect(enhanced!.properties!.skillContentHash).not.toBe(''); const internal = telemetry.internalEvents.find(e => e.eventName === 'skillContentRead'); expect(internal).toBeDefined(); expect(internal!.properties!.skillName).toBe('ext-skill'); - expect(internal!.properties!.extensionId).toBe('publisher.my-ext'); - expect(internal!.properties!.extensionVersion).toBe('1.2.3'); + expect(internal!.properties!.skillExtensionId).toBe('publisher.my-ext'); + expect(internal!.properties!.skillExtensionVersion).toBe('1.2.3'); expect(internal!.properties!.skillStorage).toBe(SkillStorage.Extension); - expect(internal!.properties!.contentHash).not.toBe(''); + expect(internal!.properties!.skillContentHash).not.toBe(''); testAccessor.dispose(); }); diff --git a/extensions/copilot/src/extension/xtab/common/diffHistoryForPrompt.ts b/extensions/copilot/src/extension/xtab/common/diffHistoryForPrompt.ts index 668eff293d9be..817d76c598da1 100644 --- a/extensions/copilot/src/extension/xtab/common/diffHistoryForPrompt.ts +++ b/extensions/copilot/src/extension/xtab/common/diffHistoryForPrompt.ts @@ -108,6 +108,11 @@ function generateDocDiff(entry: IXtabHistoryEditEntry, workspacePath: string | u continue; } + // skip no-op diffs where the old and new lines are identical + if (oldLines.length === newLines.length && oldLines.every((line, i) => line === newLines[i])) { + continue; + } + const startLineNumber = lineEditGroup[0].lineRange.startLineNumber - 1; docDiffLines.push(`@@ -${startLineNumber},${oldLines.length} +${startLineNumber},${newLines.length} @@`); diff --git a/extensions/copilot/src/platform/chat/common/commonTypes.ts b/extensions/copilot/src/platform/chat/common/commonTypes.ts index 6fb7bae58f98b..f22376f48195c 100644 --- a/extensions/copilot/src/platform/chat/common/commonTypes.ts +++ b/extensions/copilot/src/platform/chat/common/commonTypes.ts @@ -244,22 +244,46 @@ function getRateLimitMessage(fetchResult: ChatFetchError, copilotPlan: string | const resetDate = new Date(Date.now() + fetchResult.retryAfter * 1000); const resetDateString = resetDate.toLocaleString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' }); if (copilotPlan === 'free' || copilotPlan === 'individual' || copilotPlan === 'individual_pro') { + if (fetchResult.isAuto) { + return l10n.t({ + message: 'You\'ve reached your weekly rate limit. Please upgrade your plan or wait for your limit to reset on {0}. [Learn More]({1})', + args: [resetDateString, 'https://aka.ms/github-copilot-rate-limit-error'], + comment: [`{Locked=']({'}`] + }); + } + + return l10n.t({ + message: 'You\'ve reached your weekly rate limit. Please upgrade your plan, switch to the Auto model to continue working, or wait for your limit to reset on {0}. [Learn More]({1})', + args: [resetDateString, 'https://aka.ms/github-copilot-rate-limit-error'], + comment: [`{Locked=']({'}`] + }); + } + + if (fetchResult.isAuto) { return l10n.t({ - message: 'You\'ve reached your weekly rate limit. Please upgrade your plan or wait for your limit to reset on {0}. [Learn More]({1})', + message: 'You\'ve reached your weekly rate limit. Please wait for your limit to reset on {0}. [Learn More]({1})', args: [resetDateString, 'https://aka.ms/github-copilot-rate-limit-error'], comment: [`{Locked=']({'}`] }); } return l10n.t({ - message: 'You\'ve reached your weekly rate limit. Please wait for your limit to reset on {0}. [Learn More]({1})', + message: 'You\'ve reached your weekly rate limit. Please switch to the Auto model to continue working or wait for your limit to reset on {0}. [Learn More]({1})', args: [resetDateString, 'https://aka.ms/github-copilot-rate-limit-error'], comment: [`{Locked=']({'}`] }); } + if (fetchResult.isAuto) { + return l10n.t({ + message: 'You\'ve reached your weekly rate limit. Please wait {0} for your limit to reset. [Learn More]({1})', + args: [retryAfterString, 'https://aka.ms/github-copilot-rate-limit-error'], + comment: [`{Locked=']({'}`] + }); + } + return l10n.t({ - message: 'You\'ve reached your weekly rate limit. Please wait {0} for your limit to reset. [Learn More]({1})', + message: 'You\'ve reached your weekly rate limit. Please switch to the Auto model to continue working or wait {0} for your limit to reset. [Learn More]({1})', args: [retryAfterString, 'https://aka.ms/github-copilot-rate-limit-error'], comment: [`{Locked=']({'}`] }); diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index c8d08d928f70a..d8bdd501f8387 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -913,6 +913,8 @@ export namespace ConfigKey { export const ResponsesApiContextManagementEnabled = defineSetting('chat.responsesApiContextManagement.enabled', ConfigType.ExperimentBased, false); /** Enable client-side prompt_cache_key (conversationId:modelFamily) sent to Responses API */ export const ResponsesApiPromptCacheKeyEnabled = defineSetting('chat.responsesApi.promptCacheKey.enabled', ConfigType.ExperimentBased, false); + /** Enable tool search for Responses API (client-side deferred tool loading). */ + export const ResponsesApiToolSearchEnabled = defineSetting('chat.responsesApi.toolSearchTool.enabled', ConfigType.ExperimentBased, false); /** Enable updated prompt for 5.3Codex model */ export const Updated53CodexPromptEnabled = defineSetting('chat.updated53CodexPrompt.enabled', ConfigType.ExperimentBased, true); /** Enable concise prompt experiment for GPT-5.4 model */ diff --git a/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts b/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts index 60af4541fa200..ac7db4a7c4600 100644 --- a/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts +++ b/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts @@ -384,12 +384,18 @@ export function getVerbosityForModelSync(model: IChatEndpoint): 'low' | 'medium' /** * Returns true if the model supports the tool search tool. - * Matches any Claude Sonnet or Opus model with version >= 4.5. The minor - * version is bounded to 1–2 digits so date suffixes like `-20250514` + * Matches OpenAI gpt-5.4 models only when the Responses API tool search setting + * is enabled, and any Claude Sonnet or Opus model with version >= 4.5. The + * minor version is bounded to 1-2 digits so date suffixes like `-20250514` * cannot be misread as a minor version. */ -export function modelSupportsToolSearch(modelId: string): boolean { - const normalized = modelId.toLowerCase().replace(/\./g, '-'); +export function modelSupportsToolSearch(modelId: string, configurationService?: IConfigurationService, experimentationService?: IExperimentationService): boolean { + const lower = modelId.toLowerCase(); + if (isGpt54(lower)) { + return !!configurationService && !!experimentationService && isResponsesApiToolSearchEnabled(modelId, configurationService, experimentationService); + } + + const normalized = lower.replace(/\./g, '-'); const match = normalized.match(/^claude-(?:sonnet|opus)-(\d+)(?:-(\d{1,2}))?(?:-|$)/); if (!match) { return false; @@ -399,6 +405,14 @@ export function modelSupportsToolSearch(modelId: string): boolean { return major > 4 || (major === 4 && minor >= 5); } +export function isResponsesApiToolSearchEnabled( + endpoint: IChatEndpoint | string, + configurationService: IConfigurationService, + experimentationService: IExperimentationService, +): boolean { + return isGpt54(endpoint) && configurationService.getExperimentBasedConfig(ConfigKey.ResponsesApiToolSearchEnabled, experimentationService); +} + /** * Context editing is supported by: * - Claude Haiku 4.5 (claude-haiku-4-5-* or claude-haiku-4.5-*) diff --git a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts index 2dd7e74abfa86..236d56e67e585 100644 --- a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts @@ -173,7 +173,7 @@ export class ChatEndpoint implements IChatEndpoint { this.minThinkingBudget = modelMetadata.capabilities.supports.min_thinking_budget; this.maxThinkingBudget = modelMetadata.capabilities.supports.max_thinking_budget; this.supportsReasoningEffort = modelMetadata.capabilities.supports.reasoning_effort; - this.supportsToolSearch = modelMetadata.capabilities.supports.tool_search ?? modelSupportsToolSearch(this.model); + this.supportsToolSearch = modelMetadata.capabilities.supports.tool_search ?? modelSupportsToolSearch(this.model, this._configurationService, this._expService); this.supportsContextEditing = modelMetadata.capabilities.supports.context_editing ?? modelSupportsContextEditing(this.model); this._supportsStreaming = !!modelMetadata.capabilities.supports.streaming; this.customModel = modelMetadata.custom_model; diff --git a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts index 93729f900a9ff..1cb9602d145e9 100644 --- a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts @@ -14,17 +14,20 @@ import { SSEParser } from '../../../util/vs/base/common/sseParser'; import { isDefined } from '../../../util/vs/base/common/types'; import { generateUuid } from '../../../util/vs/base/common/uuid'; import { IInstantiationService, ServicesAccessor } from '../../../util/vs/platform/instantiation/common/instantiation'; +import { ChatLocation } from '../../chat/common/commonTypes'; import { ConfigKey, IConfigurationService } from '../../configuration/common/configurationService'; import { ILogService } from '../../log/common/logService'; -import { FinishedCallback, getRequestId, IResponseDelta, OpenAiResponsesFunctionTool } from '../../networking/common/fetch'; +import { CUSTOM_TOOL_SEARCH_NAME } from '../../networking/common/anthropic'; +import { FinishedCallback, getRequestId, IResponseDelta, OpenAiFunctionTool, OpenAiResponsesFunctionTool, OpenAiToolSearchTool } from '../../networking/common/fetch'; import { IChatEndpoint, ICreateEndpointBodyOptions, IEndpointBody } from '../../networking/common/networking'; import { ChatCompletion, FinishedCompletionReason, modelsWithoutResponsesContextManagement, openAIContextManagementCompactionType, OpenAIContextManagementResponse, rawMessageToCAPI, TokenLogProb } from '../../networking/common/openai'; +import { IToolDeferralService } from '../../networking/common/toolDeferralService'; import { sendEngineMessagesTelemetry, sendResponsesApiCompactionTelemetry } from '../../networking/node/chatStream'; import { IChatWebSocketManager } from '../../networking/node/chatWebSocketManager'; import { IExperimentationService } from '../../telemetry/common/nullExperimentationService'; import { ITelemetryService } from '../../telemetry/common/telemetry'; import { TelemetryData } from '../../telemetry/common/telemetryData'; -import { getVerbosityForModelSync } from '../common/chatModelCapabilities'; +import { getVerbosityForModelSync, isResponsesApiToolSearchEnabled } from '../common/chatModelCapabilities'; import { rawPartAsCompactionData } from '../common/compactionDataContainer'; import { rawPartAsPhaseData } from '../common/phaseDataContainer'; import { getIndexOfStatefulMarker, getStatefulMarkerAndIndex } from '../common/statefulMarkerContainer'; @@ -54,16 +57,74 @@ export function createResponsesRequestBody(accessor: ServicesAccessor, options: // back to the HTTP marker lookup in that case. const ignoreStatefulMarker = !!options.ignoreStatefulMarker || !!options.useWebSocket; + // Tool search: when enabled, split tools into non-deferred (included in the request) and deferred + // (excluded from the request entirely). Uses OpenAI's client-executed tool search protocol: we add + // { type: 'tool_search', execution: 'client' }. The model emits tool_search_call, which we handle via + // our ToolSearchTool embeddings search, then round-trip as tool_search_output in the next request. + 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 toolDeferralService = shouldDeferTools ? accessor.get(IToolDeferralService) : undefined; + + type ResponsesFunctionTool = OpenAI.Responses.FunctionTool & OpenAiResponsesFunctionTool; + const functionTools: ResponsesFunctionTool[] = []; + if (options.requestOptions?.tools) { + for (const tool of options.requestOptions.tools) { + if (!tool.function.name || tool.function.name.length === 0) { + continue; + } + // Always skip the tool_search function tool — 'tool_search' is a reserved namespace in the + // Responses API. Client-executed tool search uses { type: 'tool_search', execution: 'client' } instead. + if (tool.function.name === CUSTOM_TOOL_SEARCH_NAME) { + continue; + } + const isDeferred = shouldDeferTools && !toolDeferralService!.isNonDeferredTool(tool.function.name); + // Client-executed tool search: deferred tools are NOT sent in the request. + // They are returned via tool_search_output when the model searches for them. + if (isDeferred) { + continue; + } + functionTools.push({ + ...tool.function, + type: 'function', + strict: false, + parameters: (tool.function.parameters || {}) as Record, + }); + } + } + + // Build final tools array + const finalTools: Array = [...functionTools]; + if (shouldDeferTools) { + // Client-executed tool search: the model emits tool_search_call, our ToolSearchTool + // handles the embeddings search, and we return tool_search_output with full definitions. + finalTools.unshift({ + type: 'tool_search', + execution: 'client', + description: 'Search for relevant tools by describing what you need. Returns tool definitions for tools matching your query.', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Natural language description of what tool capability you are looking for.', + }, + }, + required: ['query'], + }, + } as ClientToolSearchTool); + } + + const toolsMap = options.requestOptions?.tools + ? new Map(options.requestOptions.tools.map(t => [t.function.name, t])) + : undefined; + const body: IEndpointBody = { model, - ...rawMessagesToResponseAPI(model, options.messages, ignoreStatefulMarker, webSocketStatefulMarker), + ...rawMessagesToResponseAPI(model, options.messages, ignoreStatefulMarker, webSocketStatefulMarker, toolsMap), stream: true, - tools: options.requestOptions?.tools?.map((tool): OpenAI.Responses.FunctionTool & OpenAiResponsesFunctionTool => ({ - ...tool.function, - type: 'function', - strict: false, - parameters: (tool.function.parameters || {}) as Record, - })), + tools: finalTools.length > 0 ? finalTools : undefined, // Only a subset of completion post options are supported, and some // are renamed. Handle them manually: max_output_tokens: options.postOptions.max_tokens, @@ -142,6 +203,53 @@ interface ResponseOutputItemWithPhase { phase?: string; } +// ── Responses API tool search types ────────────────────────────────── +// These match the shapes from https://developers.openai.com/api/docs/guides/tools-tool-search + +/** Client-executed tool_search tool definition for the Responses API */ +interface ClientToolSearchTool { + type: 'tool_search'; + execution: 'client'; + description: string; + parameters: Record; +} + +interface ResponsesToolSearchCall { + type: 'tool_search_call'; + id: string; + execution: 'client'; + call_id: string | null; + status: string; + arguments?: Record; +} + +/** Input item shape for a client-executed tool_search_call in conversation history */ +interface ResponsesToolSearchCallInput { + type: 'tool_search_call'; + execution: 'client'; + call_id: string; + status: string; + arguments: Record; +} + +/** Input item shape for a client-executed tool_search_output in conversation history */ +interface ResponsesToolSearchOutputInput { + type: 'tool_search_output'; + execution: 'client'; + call_id: string; + status: string; + tools: ToolSearchLoadedTool[]; +} + +/** A tool definition returned in tool_search_output */ +interface ToolSearchLoadedTool { + type: 'function'; + name: string; + description: string; + defer_loading: true; + parameters: object; +} + interface LatestCompactionOutput { readonly item: OpenAIContextManagementResponse; readonly outputIndex: number; @@ -180,7 +288,7 @@ function resolveWebSocketStatefulMarker(accessor: ServicesAccessor, options: ICr return wsManager.getStatefulMarker(options.conversationId); } -function rawMessagesToResponseAPI(modelId: string, messages: readonly Raw.ChatMessage[], ignoreStatefulMarker: boolean, webSocketStatefulMarker: string | undefined): { input: OpenAI.Responses.ResponseInputItem[]; previous_response_id?: string } { +function rawMessagesToResponseAPI(modelId: string, messages: readonly Raw.ChatMessage[], ignoreStatefulMarker: boolean, webSocketStatefulMarker: string | undefined, toolsMap?: Map): { input: OpenAI.Responses.ResponseInputItem[]; previous_response_id?: string } { const latestCompactionMessageIndex = getLatestCompactionMessageIndex(messages); const latestCompactionMessage = latestCompactionMessageIndex !== undefined ? createCompactionRoundTripMessage(messages[latestCompactionMessageIndex]) : undefined; @@ -218,6 +326,11 @@ function rawMessagesToResponseAPI(modelId: string, messages: readonly Raw.ChatMe messages = messages.slice(latestCompactionMessageIndex); } + // Track which call_ids are tool_search_calls (from client-executed tool search) + const toolSearchCallIds = new Set(); + // Track tool names loaded via tool_search_output — these need a namespace field on function_call + const toolSearchLoadedTools = new Set(); + const input: OpenAI.Responses.ResponseInputItem[] = []; for (const message of messages) { switch (message.role) { @@ -240,28 +353,63 @@ function rawMessagesToResponseAPI(modelId: string, messages: readonly Raw.ChatMe } if (message.toolCalls) { for (const toolCall of message.toolCalls) { - input.push({ type: 'function_call', name: toolCall.function.name, arguments: toolCall.function.arguments, call_id: toolCall.id }); + if (toolCall.function.name === CUSTOM_TOOL_SEARCH_NAME) { + // Client-executed tool search: emit as tool_search_call instead of function_call + toolSearchCallIds.add(toolCall.id); + let parsedArgs: Record = {}; + try { parsedArgs = JSON.parse(toolCall.function.arguments || '{}'); } catch { } + input.push({ + type: 'tool_search_call', + execution: 'client', + call_id: toolCall.id, + status: 'completed', + arguments: parsedArgs, + } satisfies ResponsesToolSearchCallInput as unknown as OpenAI.Responses.ResponseInputItem); + } else { + // Tools loaded via tool_search need a namespace field to round-trip correctly + const namespace = toolSearchLoadedTools.has(toolCall.function.name) ? toolCall.function.name : undefined; + input.push({ type: 'function_call', name: toolCall.function.name, arguments: toolCall.function.arguments, call_id: toolCall.id, ...(namespace ? { namespace } : {}) }); + } } } break; case Raw.ChatRole.Tool: if (message.toolCallId) { - const asText = message.content - .filter(c => c.type === Raw.ChatCompletionContentPartKind.Text) - .map(c => c.text) - .join(''); - const asImages = message.content - .filter(c => c.type === Raw.ChatCompletionContentPartKind.Image) - .map((c): OpenAI.Responses.ResponseInputImage => ({ - type: 'input_image', - detail: c.imageUrl.detail || 'auto', - image_url: c.imageUrl.url, - })); - - // todod@connor4312: hack while responses API only supports text output from tools - input.push({ type: 'function_call_output', call_id: message.toolCallId, output: asText }); - if (asImages.length) { - input.push({ role: 'user', content: [{ type: 'input_text', text: 'Image associated with the above tool call:' }, ...asImages] }); + if (toolSearchCallIds.has(message.toolCallId)) { + // Client-executed tool search result: convert tool names to tool_search_output with full definitions + const resultText = message.content + .filter(c => c.type === Raw.ChatCompletionContentPartKind.Text) + .map(c => c.text) + .join(''); + const loadedTools = toolsMap ? buildToolSearchOutputTools(resultText, toolsMap) : []; + for (const t of loadedTools) { + toolSearchLoadedTools.add(t.name); + } + input.push({ + type: 'tool_search_output', + execution: 'client', + call_id: message.toolCallId, + status: 'completed', + tools: loadedTools, + } satisfies ResponsesToolSearchOutputInput as unknown as OpenAI.Responses.ResponseInputItem); + } else { + const asText = message.content + .filter(c => c.type === Raw.ChatCompletionContentPartKind.Text) + .map(c => c.text) + .join(''); + const asImages = message.content + .filter(c => c.type === Raw.ChatCompletionContentPartKind.Image) + .map((c): OpenAI.Responses.ResponseInputImage => ({ + type: 'input_image', + detail: c.imageUrl.detail || 'auto', + image_url: c.imageUrl.url, + })); + + // todod@connor4312: hack while responses API only supports text output from tools + input.push({ type: 'function_call_output', call_id: message.toolCallId, output: asText }); + if (asImages.length) { + input.push({ role: 'user', content: [{ type: 'input_text', text: 'Image associated with the above tool call:' }, ...asImages] }); + } } } break; @@ -277,6 +425,29 @@ function rawMessagesToResponseAPI(modelId: string, messages: readonly Raw.ChatMe return { input, previous_response_id: previousResponseId }; } +/** + * Converts a JSON array of tool names (from ToolSearchTool) into full tool definitions + * for the tool_search_output. Falls back to an empty array on parse failure. + */ +function buildToolSearchOutputTools(resultText: string, toolsMap: Map): ToolSearchLoadedTool[] { + let toolNames: unknown; + try { toolNames = JSON.parse(resultText); } catch { return []; } + if (!Array.isArray(toolNames)) { return []; } + + return toolNames + .filter((name): name is string => typeof name === 'string' && name !== CUSTOM_TOOL_SEARCH_NAME && toolsMap.has(name)) + .map(name => { + const tool = toolsMap.get(name)!; + return { + type: 'function' as const, + name: tool.function.name, + description: tool.function.description || '', + defer_loading: true as const, + parameters: tool.function.parameters || { type: 'object', properties: {} }, + }; + }); +} + function createCompactionRoundTripMessage(message: Raw.ChatMessage): Raw.ChatMessage | undefined { if (message.role !== Raw.ChatRole.Assistant) { return undefined; @@ -475,6 +646,32 @@ export function responseApiInputToRawMessagesForLogging(body: OpenAI.Responses.R }] }); break; + default: { + // Client-executed tool search items (tool_search_call / tool_search_output) + const tsItem = item as unknown as ResponsesToolSearchCallInput | ResponsesToolSearchOutputInput; + if (tsItem.type === 'tool_search_call') { + pendingFunctionCalls.push({ + id: tsItem.call_id, + type: 'function', + function: { + name: CUSTOM_TOOL_SEARCH_NAME, + arguments: JSON.stringify(tsItem.arguments ?? {}), + } + }); + } else if (tsItem.type === 'tool_search_output') { + flushPendingFunctionCalls(); + const toolNames = tsItem.tools.map(t => t.name); + messages.push({ + role: Raw.ChatRole.Tool, + content: [{ + type: Raw.ChatCompletionContentPartKind.Text, + text: JSON.stringify(toolNames), + }], + toolCallId: tsItem.call_id, + }); + } + break; + } } } } @@ -736,6 +933,16 @@ export class OpenAIResponsesProcessor { text: '', beginToolCalls: [{ name: chunk.item.name, id: chunk.item.call_id }] }); + } else if (chunk.item.type.toString() === 'tool_search_call') { + const tsItem = chunk.item as unknown as ResponsesToolSearchCall; + if (tsItem.execution === 'client' && tsItem.call_id) { + // Client-executed tool search: treat as a regular tool call so our ToolSearchTool handles it. + this.toolCallInfo.set(chunk.output_index, { name: CUSTOM_TOOL_SEARCH_NAME, callId: tsItem.call_id, arguments: '' }); + onProgress({ + text: '', + beginToolCalls: [{ name: CUSTOM_TOOL_SEARCH_NAME, id: tsItem.call_id }] + }); + } } return; case 'response.function_call_arguments.delta': { @@ -765,6 +972,20 @@ export class OpenAIResponsesProcessor { }], phase: (chunk.item as ResponseOutputItemWithPhase).phase }); + } else if (chunk.item.type.toString() === 'tool_search_call') { + const tsCall = chunk.item as unknown as ResponsesToolSearchCall; + if (tsCall.execution === 'client' && tsCall.call_id) { + // Client-executed tool search completed: emit as a completed copilotToolCall + this.toolCallInfo.delete(chunk.output_index); + onProgress({ + text: '', + copilotToolCalls: [{ + id: tsCall.call_id, + name: CUSTOM_TOOL_SEARCH_NAME, + arguments: JSON.stringify(tsCall.arguments ?? {}), + }], + }); + } } else if (chunk.item.type === 'reasoning') { onProgress({ text: '', diff --git a/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts index 46bfd05e22a9a..cca95b8a8bc67 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts @@ -294,6 +294,53 @@ describe('responseApiInputToRawMessagesForLogging', () => { expect(result[0].role).toBe(Raw.ChatRole.Assistant); expect((result[0] as Raw.AssistantChatMessage).toolCalls).toHaveLength(2); }); + + it('converts tool_search_call and tool_search_output items to raw messages', () => { + const body: OpenAI.Responses.ResponseCreateParams = { + model: 'gpt-5-mini', + input: [ + { + type: 'tool_search_call', + execution: 'client', + call_id: 'ts_call_1', + status: 'completed', + arguments: { query: 'file editing tools' }, + } as unknown as OpenAI.Responses.ResponseInputItem, + { + type: 'tool_search_output', + execution: 'client', + call_id: 'ts_call_1', + status: 'completed', + tools: [ + { type: 'function', name: 'grep_search', description: 'Search files', defer_loading: true, parameters: {} }, + { type: 'function', name: 'file_search', description: 'Find files', defer_loading: true, parameters: {} }, + ], + } as unknown as OpenAI.Responses.ResponseInputItem + ] + }; + + const result = responseApiInputToRawMessagesForLogging(body); + + expect(result).toEqual([ + { + role: Raw.ChatRole.Assistant, + content: [], + toolCalls: [{ + id: 'ts_call_1', + type: 'function', + function: { + name: 'tool_search', + arguments: '{"query":"file editing tools"}', + } + }] + }, + { + role: Raw.ChatRole.Tool, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: '["grep_search","file_search"]' }], + toolCallId: 'ts_call_1', + } + ]); + }); }); describe('createResponsesRequestBody', () => { @@ -520,6 +567,59 @@ describe('createResponsesRequestBody', () => { accessor.dispose(); services.dispose(); }); + + it('adds namespace field only to function_call for tools loaded via tool_search_output', () => { + const services = createPlatformServices(); + const accessor = services.createTestingAccessor(); + const instantiationService = accessor.get(IInstantiationService); + const tools = [ + { type: 'function' as const, function: { name: 'tool_search', description: 'Search tools', parameters: {} } }, + { type: 'function' as const, function: { name: 'grep_search', description: 'Grep files', parameters: {} } }, + { type: 'function' as const, function: { name: 'read_file', description: 'Read a file', parameters: {} } }, + ]; + const messages: Raw.ChatMessage[] = [ + { + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'find something' }], + }, + // Assistant calls tool_search + { + role: Raw.ChatRole.Assistant, + content: [], + toolCalls: [{ id: 'ts_1', type: 'function', function: { name: 'tool_search', arguments: '{"query":"search"}' } }], + }, + // tool_search returns grep_search + { + role: Raw.ChatRole.Tool, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: '["grep_search"]' }], + toolCallId: 'ts_1', + }, + // Assistant calls grep_search (loaded via tool_search) and read_file (not loaded via tool_search) + { + role: Raw.ChatRole.Assistant, + content: [], + toolCalls: [ + { id: 'call_grep', type: 'function', function: { name: 'grep_search', arguments: '{"q":"hello"}' } }, + { id: 'call_read', type: 'function', function: { name: 'read_file', arguments: '{"path":"foo.ts"}' } }, + ], + }, + ]; + + const body = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, { ...createRequestOptions(messages, false), requestOptions: { tools } }, testEndpoint.model, testEndpoint)); + + // grep_search was loaded via tool_search_output — should have namespace + const grepCall = (body.input as unknown[]).find((item: any) => item.type === 'function_call' && item.name === 'grep_search') as any; + expect(grepCall).toBeDefined(); + expect(grepCall.namespace).toBe('grep_search'); + + // read_file was NOT loaded via tool_search — should NOT have namespace + const readCall = (body.input as unknown[]).find((item: any) => item.type === 'function_call' && item.name === 'read_file') as any; + expect(readCall).toBeDefined(); + expect(readCall).not.toHaveProperty('namespace'); + + accessor.dispose(); + services.dispose(); + }); }); describe('processResponseFromChatEndpoint telemetry', () => { diff --git a/extensions/copilot/src/platform/endpoint/node/test/responsesApiToolSearch.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/responsesApiToolSearch.spec.ts new file mode 100644 index 0000000000000..3d4dd218aacec --- /dev/null +++ b/extensions/copilot/src/platform/endpoint/node/test/responsesApiToolSearch.spec.ts @@ -0,0 +1,321 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Raw } from '@vscode/prompt-tsx'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; +import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; +import { ChatLocation } from '../../../chat/common/commonTypes'; +import { ConfigKey, IConfigurationService } from '../../../configuration/common/configurationService'; +import { InMemoryConfigurationService } from '../../../configuration/test/common/inMemoryConfigurationService'; +import { IResponseDelta } from '../../../networking/common/fetch'; +import { IChatEndpoint, ICreateEndpointBodyOptions } from '../../../networking/common/networking'; +import { IToolDeferralService } from '../../../networking/common/toolDeferralService'; +import { TelemetryData } from '../../../telemetry/common/telemetryData'; +import { SpyingTelemetryService } from '../../../telemetry/node/spyingTelemetryService'; +import { createPlatformServices } from '../../../test/node/services'; +import { createResponsesRequestBody, OpenAIResponsesProcessor } from '../responsesApi'; + +function createMockEndpoint(model: string): IChatEndpoint { + return { + model, + family: model, + modelProvider: 'openai', + supportsToolCalls: true, + supportsVision: false, + supportsPrediction: false, + showInModelPicker: true, + isFallback: false, + maxOutputTokens: 4096, + modelMaxPromptTokens: 128000, + urlOrRequestMetadata: 'https://test', + name: model, + version: '1', + tokenizer: 'cl100k_base' as any, + acquireTokenizer: () => { throw new Error('Not implemented'); }, + processResponseFromChatEndpoint: () => { throw new Error('Not implemented'); }, + makeChatRequest: () => { throw new Error('Not implemented'); }, + makeChatRequest2: () => { throw new Error('Not implemented'); }, + createRequestBody: () => { throw new Error('Not implemented'); }, + cloneWithTokenOverride() { return this; }, + } as unknown as IChatEndpoint; +} + +function createMockOptions(overrides: Partial = {}): ICreateEndpointBodyOptions { + return { + debugName: 'test', + messages: [{ role: Raw.ChatRole.User, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }] }], + location: ChatLocation.Agent, + finishedCb: undefined, + requestId: 'test-req-1', + postOptions: { max_tokens: 4096 }, + requestOptions: { + tools: [ + { type: 'function', function: { name: 'read_file', description: 'Read a file', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } } }, + { 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: {} } } }, + ] + }, + ...overrides, + } as ICreateEndpointBodyOptions; +} + +describe('createResponsesRequestBody tools', () => { + let disposables: DisposableStore; + let services: ReturnType; + let accessor: ReturnType['createTestingAccessor']>; + + beforeEach(() => { + disposables = new DisposableStore(); + services = createPlatformServices(disposables); + const coreNonDeferred = new Set(['read_file', 'list_dir', 'grep_search', 'semantic_search', 'file_search', + 'replace_string_in_file', 'create_file', 'run_in_terminal', 'get_terminal_output', + 'get_errors', 'manage_todo_list', 'runSubagent', 'search_subagent', 'execution_subagent', + 'runTests', 'tool_search', 'view_image', 'fetch_webpage']); + services.define(IToolDeferralService, { _serviceBrand: undefined, isNonDeferredTool: (name: string) => coreNonDeferred.has(name) }); + accessor = services.createTestingAccessor(); + }); + + it('passes tools through without defer_loading when tool search disabled', () => { + const endpoint = createMockEndpoint('gpt-5.4-preview'); + const configService = accessor.get(IConfigurationService) as InMemoryConfigurationService; + configService.setConfig(ConfigKey.ResponsesApiToolSearchEnabled, false); + + const body = accessor.get(IInstantiationService).invokeFunction( + createResponsesRequestBody, createMockOptions(), endpoint.model, endpoint + ); + + const tools = body.tools as any[]; + expect(tools).toBeDefined(); + expect(tools.find(t => t.type === 'tool_search')).toBeUndefined(); + expect(tools.every(t => !t.defer_loading)).toBe(true); + }); + + it('adds client tool_search and defer_loading when enabled', () => { + const endpoint = createMockEndpoint('gpt-5.4-preview'); + const configService = accessor.get(IConfigurationService) as InMemoryConfigurationService; + configService.setConfig(ConfigKey.ResponsesApiToolSearchEnabled, true); + + const body = accessor.get(IInstantiationService).invokeFunction( + createResponsesRequestBody, createMockOptions(), endpoint.model, endpoint + ); + + const tools = body.tools as any[]; + expect(tools).toBeDefined(); + + // Should have client-executed tool_search + const toolSearchTool = tools.find(t => t.type === 'tool_search'); + expect(toolSearchTool).toBeDefined(); + expect(toolSearchTool.execution).toBe('client'); + + // Non-deferred tools should be present without defer_loading + expect(tools.find(t => t.name === 'read_file')?.defer_loading).toBeUndefined(); + expect(tools.find(t => t.name === 'grep_search')?.defer_loading).toBeUndefined(); + + // Deferred tools should NOT be in the request (client-executed mode excludes them entirely) + expect(tools.find(t => t.name === 'some_mcp_tool')).toBeUndefined(); + expect(tools.find(t => t.name === 'another_deferred_tool')).toBeUndefined(); + }); + + it('does not defer tools for unsupported models', () => { + const endpoint = createMockEndpoint('gpt-4o'); + const configService = accessor.get(IConfigurationService) as InMemoryConfigurationService; + configService.setConfig(ConfigKey.ResponsesApiToolSearchEnabled, true); + + const body = accessor.get(IInstantiationService).invokeFunction( + createResponsesRequestBody, createMockOptions(), endpoint.model, endpoint + ); + + const tools = body.tools as any[]; + expect(tools.find(t => t.type === 'tool_search')).toBeUndefined(); + expect(tools.every(t => !t.defer_loading)).toBe(true); + }); + + it('does not defer tools for non-Agent locations', () => { + const endpoint = createMockEndpoint('gpt-5.4-preview'); + const configService = accessor.get(IConfigurationService) as InMemoryConfigurationService; + configService.setConfig(ConfigKey.ResponsesApiToolSearchEnabled, true); + + const options = createMockOptions({ location: ChatLocation.Panel }); + const body = accessor.get(IInstantiationService).invokeFunction( + createResponsesRequestBody, options, endpoint.model, endpoint + ); + + const tools = body.tools as any[]; + expect(tools.find(t => t.type === 'tool_search')).toBeUndefined(); + expect(tools.every(t => !t.defer_loading)).toBe(true); + }); + + it('always filters tool_search function tool from tools array', () => { + const endpoint = createMockEndpoint('gpt-5.4-preview'); + const configService = accessor.get(IConfigurationService) as InMemoryConfigurationService; + configService.setConfig(ConfigKey.ResponsesApiToolSearchEnabled, false); + + const options = createMockOptions({ + requestOptions: { + tools: [ + { type: 'function', function: { name: 'read_file', description: 'Read a file', parameters: { type: 'object', properties: {} } } }, + { type: 'function', function: { name: 'tool_search', description: 'Search tools', parameters: { type: 'object', properties: {} } } }, + ] + } + }); + const body = accessor.get(IInstantiationService).invokeFunction( + createResponsesRequestBody, options, endpoint.model, endpoint + ); + + const tools = body.tools as any[]; + expect(tools.find(t => t.name === 'tool_search')).toBeUndefined(); + expect(tools.find(t => t.name === 'read_file')).toBeDefined(); + }); + + it('converts tool_search history even when feature flag is off', () => { + const endpoint = createMockEndpoint('gpt-5.4-preview'); + const configService = accessor.get(IConfigurationService) as InMemoryConfigurationService; + configService.setConfig(ConfigKey.ResponsesApiToolSearchEnabled, false); + + const messages: Raw.ChatMessage[] = [ + { role: Raw.ChatRole.User, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }] }, + { + role: Raw.ChatRole.Assistant, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Let me search for tools.' }], + toolCalls: [{ id: 'call_ts1', type: 'function', function: { name: 'tool_search', arguments: '{"query":"file tools"}' } }], + }, + { + role: Raw.ChatRole.Tool, + toolCallId: 'call_ts1', + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: '["read_file","grep_search"]' }], + }, + ]; + + const options = createMockOptions({ messages }); + const body = accessor.get(IInstantiationService).invokeFunction( + createResponsesRequestBody, options, endpoint.model, endpoint + ); + + const input = body.input as any[]; + // tool_search tool call should be converted to tool_search_call, not function_call + const toolSearchCall = input.find(i => i.type === 'tool_search_call'); + expect(toolSearchCall).toBeDefined(); + expect(toolSearchCall.execution).toBe('client'); + expect(toolSearchCall.call_id).toBe('call_ts1'); + + // tool_search result should be converted to tool_search_output, not function_call_output + const toolSearchOutput = input.find(i => i.type === 'tool_search_output'); + expect(toolSearchOutput).toBeDefined(); + expect(toolSearchOutput.execution).toBe('client'); + expect(toolSearchOutput.call_id).toBe('call_ts1'); + + // tool_search_output should not include tool_search itself in loaded tools + const loadedToolNames = (toolSearchOutput.tools as any[]).map((t: any) => t.name); + expect(loadedToolNames).not.toContain('tool_search'); + + // Should not have any function_call with name tool_search + const badFunctionCall = input.find(i => i.type === 'function_call' && i.name === 'tool_search'); + expect(badFunctionCall).toBeUndefined(); + }); + + it('converts tool_search history when current request has no tools', () => { + const endpoint = createMockEndpoint('gpt-5.4-preview'); + const messages: Raw.ChatMessage[] = [ + { role: Raw.ChatRole.User, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }] }, + { + role: Raw.ChatRole.Assistant, + content: [], + toolCalls: [{ id: 'call_ts_no_tools', type: 'function', function: { name: 'tool_search', arguments: '{"query":"file tools"}' } }], + }, + { + role: Raw.ChatRole.Tool, + toolCallId: 'call_ts_no_tools', + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: '["read_file"]' }], + }, + ]; + + const options = createMockOptions({ messages, requestOptions: undefined }); + const body = accessor.get(IInstantiationService).invokeFunction( + createResponsesRequestBody, options, endpoint.model, endpoint + ); + + const input = body.input as Array<{ type?: string; name?: string; execution?: string; call_id?: string; tools?: unknown[] }>; + expect(input.find(i => i.type === 'tool_search_call')).toMatchObject({ + type: 'tool_search_call', + execution: 'client', + call_id: 'call_ts_no_tools', + }); + expect(input.find(i => i.type === 'tool_search_output')).toMatchObject({ + type: 'tool_search_output', + execution: 'client', + call_id: 'call_ts_no_tools', + tools: [], + }); + expect(input.find(i => i.type === 'function_call' && i.name === 'tool_search')).toBeUndefined(); + }); +}); + +describe('OpenAIResponsesProcessor tool search events', () => { + function createProcessor() { + const telemetryData = TelemetryData.createAndMarkAsIssued({}, {}); + const telemetryService = new SpyingTelemetryService(); + const ds = new DisposableStore(); + const services = createPlatformServices(ds); + const accessor = services.createTestingAccessor(); + return accessor.get(IInstantiationService).createInstance(OpenAIResponsesProcessor, telemetryData, telemetryService, 'req-123', 'gh-req-456', '', undefined); + } + + function collectDeltas(processor: OpenAIResponsesProcessor, events: any[]): IResponseDelta[] { + const deltas: IResponseDelta[] = []; + const finishedCb = async (_text: string, _index: number, delta: IResponseDelta) => { + deltas.push(delta); + return undefined; + }; + for (const event of events) { + processor.push({ sequence_number: 0, ...event }, finishedCb); + } + return deltas; + } + + it('handles client tool_search_call as copilotToolCall', () => { + const processor = createProcessor(); + const deltas = collectDeltas(processor, [ + { + type: 'response.output_item.added', + output_index: 0, + item: { + type: 'tool_search_call' as any, + id: 'ts_002', + execution: 'client', + call_id: 'call_abc', + status: 'in_progress', + arguments: {}, + } as any, + }, + { + type: 'response.output_item.done', + output_index: 0, + item: { + type: 'tool_search_call' as any, + id: 'ts_002', + execution: 'client', + call_id: 'call_abc', + status: 'completed', + arguments: { query: 'Find shipping tools' }, + } as any, + } + ]); + + // First delta: beginToolCalls for tool_search + expect(deltas[0].beginToolCalls).toBeDefined(); + expect(deltas[0].beginToolCalls![0].name).toBe('tool_search'); + expect(deltas[0].beginToolCalls![0].id).toBe('call_abc'); + + // Second delta: completed copilotToolCall + expect(deltas[1].copilotToolCalls).toBeDefined(); + expect(deltas[1].copilotToolCalls![0]).toMatchObject({ + id: 'call_abc', + name: 'tool_search', + arguments: '{"query":"Find shipping tools"}', + }); + }); +}); diff --git a/extensions/copilot/src/platform/endpoint/test/node/chatModelCapabilities.spec.ts b/extensions/copilot/src/platform/endpoint/test/node/chatModelCapabilities.spec.ts index b083bfc045012..b72246757d034 100644 --- a/extensions/copilot/src/platform/endpoint/test/node/chatModelCapabilities.spec.ts +++ b/extensions/copilot/src/platform/endpoint/test/node/chatModelCapabilities.spec.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { describe, expect, test } from 'vitest'; +import { ConfigKey, IConfigurationService } from '../../../configuration/common/configurationService'; import type { IChatEndpoint } from '../../../networking/common/networking'; +import { IExperimentationService } from '../../../telemetry/common/nullExperimentationService'; import { modelSupportsPDFDocuments, modelSupportsToolSearch } from '../../common/chatModelCapabilities'; function fakeModel(family: string) { @@ -61,7 +63,18 @@ describe('modelSupportsToolSearch', () => { expect(modelSupportsToolSearch('claude-3-opus')).toBe(false); }); - test('rejects non-Claude models', () => { + test('supports OpenAI gpt-5.4 models when the setting is enabled', () => { + const configurationService = { + getExperimentBasedConfig: (key: unknown) => key === ConfigKey.ResponsesApiToolSearchEnabled, + } as unknown as IConfigurationService; + const experimentationService = {} as IExperimentationService; + + expect(modelSupportsToolSearch('gpt-5.4', configurationService, experimentationService)).toBe(true); + expect(modelSupportsToolSearch('gpt-5.4-preview', configurationService, experimentationService)).toBe(true); + expect(modelSupportsToolSearch('gpt-5.4')).toBe(false); + }); + + test('rejects other non-Claude models', () => { expect(modelSupportsToolSearch('gpt-5')).toBe(false); expect(modelSupportsToolSearch('gemini-2.5-pro')).toBe(false); expect(modelSupportsToolSearch('o4-mini')).toBe(false); diff --git a/extensions/copilot/src/platform/networking/common/fetch.ts b/extensions/copilot/src/platform/networking/common/fetch.ts index 02a3f985c1417..3018b453f72b8 100644 --- a/extensions/copilot/src/platform/networking/common/fetch.ts +++ b/extensions/copilot/src/platform/networking/common/fetch.ts @@ -288,7 +288,17 @@ export interface OpenAiResponsesFunctionTool extends OpenAiFunctionDef { type: 'function'; } -export function isOpenAiFunctionTool(tool: OpenAiResponsesFunctionTool | OpenAiFunctionTool | AnthropicMessagesTool): tool is OpenAiFunctionTool { +/** OpenAI Responses API client-executed tool_search tool declaration. See https://developers.openai.com/api/docs/guides/tools-tool-search */ +export interface OpenAiToolSearchTool { + type: 'tool_search'; + execution: 'client'; + /** Description for client-executed tool search. */ + description?: string; + /** Parameters schema for client-executed tool search. */ + parameters?: Record; +} + +export function isOpenAiFunctionTool(tool: OpenAiResponsesFunctionTool | OpenAiFunctionTool | AnthropicMessagesTool | OpenAiToolSearchTool): tool is OpenAiFunctionTool { return (tool as OpenAiFunctionTool).function !== undefined; } diff --git a/extensions/copilot/src/platform/networking/common/networking.ts b/extensions/copilot/src/platform/networking/common/networking.ts index 8c70a781c1e53..22368c55f680d 100644 --- a/extensions/copilot/src/platform/networking/common/networking.ts +++ b/extensions/copilot/src/platform/networking/common/networking.ts @@ -19,7 +19,7 @@ import { ILogService } from '../../log/common/logService'; import { ITelemetryService, TelemetryProperties } from '../../telemetry/common/telemetry'; import { TelemetryData } from '../../telemetry/common/telemetryData'; import { AnthropicMessagesTool, ContextManagement } from './anthropic'; -import { FinishedCallback, OpenAiFunctionTool, OpenAiResponsesFunctionTool, OptionalChatRequestParams, Prediction } from './fetch'; +import { FinishedCallback, OpenAiFunctionTool, OpenAiResponsesFunctionTool, OpenAiToolSearchTool, OptionalChatRequestParams, Prediction } from './fetch'; import { FetcherId, FetchOptions, IAbortController, IFetcherService, PaginationOptions, Response } from './fetcherService'; import { ChatCompletion, OpenAIContextManagement, RawMessageConversionCallback, rawMessageToCAPI } from './openai'; @@ -62,7 +62,7 @@ const requestTimeoutMs = 30 * 1000; // 30 seconds */ export interface IEndpointBody { /** General or completions: */ - tools?: (OpenAiFunctionTool | OpenAiResponsesFunctionTool | AnthropicMessagesTool)[]; + tools?: (OpenAiFunctionTool | OpenAiResponsesFunctionTool | AnthropicMessagesTool | OpenAiToolSearchTool)[]; model?: string; previous_response_id?: string; max_tokens?: number; diff --git a/extensions/theme-defaults/themes/2026-dark.json b/extensions/theme-defaults/themes/2026-dark.json index 180c0a5a7f1b4..c60093dbfaecb 100644 --- a/extensions/theme-defaults/themes/2026-dark.json +++ b/extensions/theme-defaults/themes/2026-dark.json @@ -258,9 +258,9 @@ "gauge.errorBackground": "#F287724D", "chat.requestBubbleBackground": "#ffffff13", "chat.requestBubbleHoverBackground": "#ffffff22", - "chat.inputWorkingBorderColor1": "#E8E8EC", - "chat.inputWorkingBorderColor2": "#8A8A92", - "chat.inputWorkingBorderColor3": "#3A3A40", + "chat.inputWorkingBorderColor1": "#297AA0", + "chat.inputWorkingBorderColor2": "#1C546F", + "chat.inputWorkingBorderColor3": "#5BA8CC", "editorCommentsWidget.rangeBackground": "#488FAE26", "editorCommentsWidget.rangeActiveBackground": "#488FAE46", "charts.foreground": "#CCCCCC", diff --git a/extensions/theme-defaults/themes/2026-light.json b/extensions/theme-defaults/themes/2026-light.json index 8d7dcf21abbb5..95409bc8e65f3 100644 --- a/extensions/theme-defaults/themes/2026-light.json +++ b/extensions/theme-defaults/themes/2026-light.json @@ -261,9 +261,9 @@ "chat.requestBubbleBackground": "#EEF4FB", "chat.requestBubbleHoverBackground": "#E6EDFA", "chat.thinkingShimmer": "#999999", - "chat.inputWorkingBorderColor1": "#B8B8C0", - "chat.inputWorkingBorderColor2": "#7A7A82", - "chat.inputWorkingBorderColor3": "#2E2E34", + "chat.inputWorkingBorderColor1": "#0069CC", + "chat.inputWorkingBorderColor2": "#004A99", + "chat.inputWorkingBorderColor3": "#3399E6", "editorCommentsWidget.rangeBackground": "#EEF4FB", "editorCommentsWidget.rangeActiveBackground": "#E6EDFA", "charts.foreground": "#202020", diff --git a/extensions/typescript-language-features/.vscodeignore b/extensions/typescript-language-features/.vscodeignore index c9b6c88a79cc5..5c8d22eb9641c 100644 --- a/extensions/typescript-language-features/.vscodeignore +++ b/extensions/typescript-language-features/.vscodeignore @@ -5,6 +5,7 @@ test/** test-workspace/** out/** tsconfig*.json +**/*.tsbuildinfo esbuild*.mts cgmanifest.json package-lock.json diff --git a/extensions/typescript-language-features/src/experimentationService.ts b/extensions/typescript-language-features/src/experimentationService.ts index 5dc458277d46d..9bb49c8926210 100644 --- a/extensions/typescript-language-features/src/experimentationService.ts +++ b/extensions/typescript-language-features/src/experimentationService.ts @@ -9,7 +9,7 @@ import * as tas from 'vscode-tas-client'; import { IExperimentationTelemetryReporter } from './experimentTelemetryReporter'; interface ExperimentTypes { - // None for now. + suggestNativePreview: boolean; } export class ExperimentationService { @@ -24,8 +24,8 @@ export class ExperimentationService { public async getTreatmentVariable(name: K, defaultValue: ExperimentTypes[K]): Promise { const experimentationService = await this._experimentationServicePromise; try { - const treatmentVariable = experimentationService.getTreatmentVariableAsync('vscode', name, /*checkCache*/ true) as Promise; - return treatmentVariable; + const treatmentVariable = await experimentationService.getTreatmentVariableAsync('vscode', name, /*checkCache*/ true) as ExperimentTypes[K]; + return treatmentVariable ?? defaultValue; } catch { return defaultValue; } @@ -36,7 +36,8 @@ export async function createTasExperimentationService( reporter: IExperimentationTelemetryReporter, id: string, version: string, - globalState: vscode.Memento): Promise { + globalState: vscode.Memento +): Promise { let targetPopulation: tas.TargetPopulation; switch (vscode.env.uriScheme) { case 'vscode': diff --git a/extensions/typescript-language-features/src/extension.ts b/extensions/typescript-language-features/src/extension.ts index fb1ae1967d4bd..4b9a4533d3f5f 100644 --- a/extensions/typescript-language-features/src/extension.ts +++ b/extensions/typescript-language-features/src/extension.ts @@ -21,6 +21,7 @@ import { PluginManager } from './tsServer/plugins'; import { ElectronServiceProcessFactory } from './tsServer/serverProcess.electron'; import { DiskTypeScriptVersionProvider } from './tsServer/versionProvider.electron'; import { ActiveJsTsEditorTracker } from './ui/activeJsTsEditorTracker'; +import { suggestNativePreview } from './ui/suggestNativePreview'; import { onCaseInsensitiveFileSystem } from './utils/fs.electron'; import { Lazy } from './utils/lazy'; import { getPackageInfo } from './utils/packageInfo'; @@ -48,9 +49,8 @@ export function activate( experimentTelemetryReporter = new ExperimentationTelemetryReporter(vscTelemetryReporter); context.subscriptions.push(experimentTelemetryReporter); - // Currently we have no experiments, but creating the service adds the appropriate - // shared properties to the ExperimentationTelemetryReporter we just created. - new ExperimentationService(experimentTelemetryReporter, id, version, context.globalState); + const experimentationService = new ExperimentationService(experimentTelemetryReporter, id, version, context.globalState); + suggestNativePreview(context, experimentationService); } // Register features that work in both TSGO and non-TSGO modes diff --git a/extensions/typescript-language-features/src/ui/suggestNativePreview.ts b/extensions/typescript-language-features/src/ui/suggestNativePreview.ts new file mode 100644 index 0000000000000..8b919b95bde57 --- /dev/null +++ b/extensions/typescript-language-features/src/ui/suggestNativePreview.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { tsNativeExtensionId } from '../commands/useTsgo'; +import { ExperimentationService } from '../experimentationService'; + +const suggestNativePreviewStorageKey = 'typescript.suggestNativePreview.dismissed'; + +export async function suggestNativePreview( + context: vscode.ExtensionContext, + experimentationService: ExperimentationService, +): Promise { + if (context.globalState.get(suggestNativePreviewStorageKey)) { + return; + } + + // Only show when the window is active + if (!vscode.window.state.active) { + return; + } + + // Only show when the nightly extension is installed + if (!vscode.extensions.getExtension('ms-vscode.vscode-typescript-next')) { + return; + } + + // Don't show if the native preview extension is already installed + if (vscode.extensions.getExtension(tsNativeExtensionId)) { + // Also don't prompt in the future + await context.globalState.update(suggestNativePreviewStorageKey, true); + return; + } + + const inExperiment = await experimentationService.getTreatmentVariable('suggestNativePreview', false); + if (!inExperiment) { + return; + } + + const install: vscode.MessageItem = { title: vscode.l10n.t("Install") }; + const learnMore: vscode.MessageItem = { title: vscode.l10n.t("Learn More") }; + const dismiss: vscode.MessageItem = { title: vscode.l10n.t("Don't Show Again") }; + + const selection = await vscode.window.showInformationMessage( + vscode.l10n.t("Try TypeScript 7 Native Preview for significantly faster type checking and language features."), + {}, + install, + learnMore, + dismiss, + ); + // Don't show again + await context.globalState.update(suggestNativePreviewStorageKey, true); + + if (selection === install) { + await vscode.commands.executeCommand('workbench.extensions.installExtension', tsNativeExtensionId); + } else if (selection === learnMore) { + await vscode.env.openExternal(vscode.Uri.parse('https://aka.ms/vscode-try-ts-7-learn-more')); + } +} diff --git a/package-lock.json b/package-lock.json index ef94cb0371075..c8b06a63a58ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "MIT", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.42", - "@github/copilot": "^1.0.28", + "@github/copilot": "^1.0.34", "@github/copilot-sdk": "^0.2.2", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", @@ -1072,26 +1072,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.28.tgz", - "integrity": "sha512-S1Y+KnhywjIsK1DzskoCqPVC3uURohvCRyDkGPWXvMw+lXO5ryOJvHFZDDw7MSRjT7ea7T0m8e3yKdK0OxJhnw==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.34.tgz", + "integrity": "sha512-jFYulj1v00b3j43Er9+WwhZ/XldGq7+gti2s2pRhrdPwYEd1PMvscDZwRa/1iUBz/XQ5HUGac1tD8P7+VUpWjg==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.28", - "@github/copilot-darwin-x64": "1.0.28", - "@github/copilot-linux-arm64": "1.0.28", - "@github/copilot-linux-x64": "1.0.28", - "@github/copilot-win32-arm64": "1.0.28", - "@github/copilot-win32-x64": "1.0.28" + "@github/copilot-darwin-arm64": "1.0.34", + "@github/copilot-darwin-x64": "1.0.34", + "@github/copilot-linux-arm64": "1.0.34", + "@github/copilot-linux-x64": "1.0.34", + "@github/copilot-win32-arm64": "1.0.34", + "@github/copilot-win32-x64": "1.0.34" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.28.tgz", - "integrity": "sha512-Bkis5dkOsdgaK95j/8mgIGSxHlRuL211Wa3S4MeeYGrilZweaG20sa0jktzagL6XFxfPRKBC87E+fDFyXz1L3g==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.34.tgz", + "integrity": "sha512-g94EhSLd3a6fckZ6xb/zP2DZJZEx7kONWdOoDiHXUtSqc4RiZ7OBq1EwT4WrPY1lsmy9sioJIcZSGzJd0C1M7Q==", "cpu": [ "arm64" ], @@ -1105,9 +1105,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.28.tgz", - "integrity": "sha512-0RIabmr05KgPPUcD4kpKNBGg/eRwJF2NrYtibDUCIRFWKZu7q0m9c9EURpW0wOO32cXZtAQ+BmJIGlqfCkt6gA==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.34.tgz", + "integrity": "sha512-tIgFEZV0ohCF/VgTODJWre3xURsvEd+6IPN/HPKWxG6AXtJOxzjlr5kLYYdPHdNlHNmSxGQw8fWsN2FZ4nyDdw==", "cpu": [ "x64" ], @@ -1121,9 +1121,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.28.tgz", - "integrity": "sha512-A/zQ4ifN+FSSEHdPHajv5UwygS5BOQ8l1AJMYdVBnnuqVX9bCcRAJJ4S/F60AnaDimzDvVuYSe3lYXRYxz3M5A==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.34.tgz", + "integrity": "sha512-feqjEetrlqBUhYskIsPmwACQOWO99cvRpKwIFl3OlEjWoj+//HA7yXh49UIe0gD8wQUI8hy05uVz3K2/xti2nQ==", "cpu": [ "arm64" ], @@ -1137,9 +1137,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.28.tgz", - "integrity": "sha512-0VqoW9hj7qKj+eH2un9E7zn9AbassTZHkKQPsd8yPvLsmPaNJgsHMYDrCCNZNol2ZSGt/XskTfmWQaQM6BoBfg==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.34.tgz", + "integrity": "sha512-3l0rZZqmceklHizJaaO+Iy2PsAZpVZS9Mn9VYnVcY/8Yzt4Y2hmXSFcKVfc4l+JlhFsPs7trhMdIkfwkjaKPLg==", "cpu": [ "x64" ], @@ -1176,9 +1176,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.28.tgz", - "integrity": "sha512-f28NKudBtIXTpIliHGJbRhEfCItsXKWNzXzgqgmP8FZB+JYrqG/ysU2qCUCxhpv3PLjMLWqnsWs+mIvVLTH9zw==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.34.tgz", + "integrity": "sha512-06kEJO3iyohmAqF4iIbOxOfWLFSIpLDJ1L1oEHRtouMrH2Ll1wrUjsoQT1gXgBOv7rifl25qx/Avx5zKqvuORw==", "cpu": [ "arm64" ], @@ -1192,9 +1192,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.28.tgz", - "integrity": "sha512-b9ZEx2i5P7DZTP66FXTfwf81r5kbAqs2GEJjDdevCwxH7cRexqM9eBxQGj1zGtm4qXF7JGK2eH6Ay7NC28m1Iw==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.34.tgz", + "integrity": "sha512-QLL8pS4q2TTyQbClEXxqXtQGPr4lk+pwc8hPMUL7iw7HGDOvs1WCLMT1ZSDPPcxSrTnR/dURX5za1NMA8uF/fw==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 1488774c81373..2ccb29d632b74 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ }, "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.42", - "@github/copilot": "^1.0.28", + "@github/copilot": "^1.0.34", "@github/copilot-sdk": "^0.2.2", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", diff --git a/product.json b/product.json index 02121c8724b41..9720e94c1e6bb 100644 --- a/product.json +++ b/product.json @@ -3,6 +3,7 @@ "nameLong": "Code - OSS", "applicationName": "code-oss", "dataFolderName": ".vscode-oss", + "sharedDataFolderName": ".vscode-oss-shared", "win32MutexName": "vscodeoss", "licenseName": "MIT", "licenseUrl": "https://github.com/microsoft/vscode/blob/main/LICENSE.txt", diff --git a/remote/package-lock.json b/remote/package-lock.json index 24565628e403c..65394f951c81e 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.42", - "@github/copilot": "^1.0.28", + "@github/copilot": "^1.0.34", "@github/copilot-sdk": "^0.2.2", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", @@ -83,26 +83,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.28.tgz", - "integrity": "sha512-S1Y+KnhywjIsK1DzskoCqPVC3uURohvCRyDkGPWXvMw+lXO5ryOJvHFZDDw7MSRjT7ea7T0m8e3yKdK0OxJhnw==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.34.tgz", + "integrity": "sha512-jFYulj1v00b3j43Er9+WwhZ/XldGq7+gti2s2pRhrdPwYEd1PMvscDZwRa/1iUBz/XQ5HUGac1tD8P7+VUpWjg==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.28", - "@github/copilot-darwin-x64": "1.0.28", - "@github/copilot-linux-arm64": "1.0.28", - "@github/copilot-linux-x64": "1.0.28", - "@github/copilot-win32-arm64": "1.0.28", - "@github/copilot-win32-x64": "1.0.28" + "@github/copilot-darwin-arm64": "1.0.34", + "@github/copilot-darwin-x64": "1.0.34", + "@github/copilot-linux-arm64": "1.0.34", + "@github/copilot-linux-x64": "1.0.34", + "@github/copilot-win32-arm64": "1.0.34", + "@github/copilot-win32-x64": "1.0.34" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.28.tgz", - "integrity": "sha512-Bkis5dkOsdgaK95j/8mgIGSxHlRuL211Wa3S4MeeYGrilZweaG20sa0jktzagL6XFxfPRKBC87E+fDFyXz1L3g==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.34.tgz", + "integrity": "sha512-g94EhSLd3a6fckZ6xb/zP2DZJZEx7kONWdOoDiHXUtSqc4RiZ7OBq1EwT4WrPY1lsmy9sioJIcZSGzJd0C1M7Q==", "cpu": [ "arm64" ], @@ -116,9 +116,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.28.tgz", - "integrity": "sha512-0RIabmr05KgPPUcD4kpKNBGg/eRwJF2NrYtibDUCIRFWKZu7q0m9c9EURpW0wOO32cXZtAQ+BmJIGlqfCkt6gA==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.34.tgz", + "integrity": "sha512-tIgFEZV0ohCF/VgTODJWre3xURsvEd+6IPN/HPKWxG6AXtJOxzjlr5kLYYdPHdNlHNmSxGQw8fWsN2FZ4nyDdw==", "cpu": [ "x64" ], @@ -132,9 +132,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.28.tgz", - "integrity": "sha512-A/zQ4ifN+FSSEHdPHajv5UwygS5BOQ8l1AJMYdVBnnuqVX9bCcRAJJ4S/F60AnaDimzDvVuYSe3lYXRYxz3M5A==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.34.tgz", + "integrity": "sha512-feqjEetrlqBUhYskIsPmwACQOWO99cvRpKwIFl3OlEjWoj+//HA7yXh49UIe0gD8wQUI8hy05uVz3K2/xti2nQ==", "cpu": [ "arm64" ], @@ -148,9 +148,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.28.tgz", - "integrity": "sha512-0VqoW9hj7qKj+eH2un9E7zn9AbassTZHkKQPsd8yPvLsmPaNJgsHMYDrCCNZNol2ZSGt/XskTfmWQaQM6BoBfg==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.34.tgz", + "integrity": "sha512-3l0rZZqmceklHizJaaO+Iy2PsAZpVZS9Mn9VYnVcY/8Yzt4Y2hmXSFcKVfc4l+JlhFsPs7trhMdIkfwkjaKPLg==", "cpu": [ "x64" ], @@ -187,9 +187,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.28.tgz", - "integrity": "sha512-f28NKudBtIXTpIliHGJbRhEfCItsXKWNzXzgqgmP8FZB+JYrqG/ysU2qCUCxhpv3PLjMLWqnsWs+mIvVLTH9zw==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.34.tgz", + "integrity": "sha512-06kEJO3iyohmAqF4iIbOxOfWLFSIpLDJ1L1oEHRtouMrH2Ll1wrUjsoQT1gXgBOv7rifl25qx/Avx5zKqvuORw==", "cpu": [ "arm64" ], @@ -203,9 +203,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.28.tgz", - "integrity": "sha512-b9ZEx2i5P7DZTP66FXTfwf81r5kbAqs2GEJjDdevCwxH7cRexqM9eBxQGj1zGtm4qXF7JGK2eH6Ay7NC28m1Iw==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.34.tgz", + "integrity": "sha512-QLL8pS4q2TTyQbClEXxqXtQGPr4lk+pwc8hPMUL7iw7HGDOvs1WCLMT1ZSDPPcxSrTnR/dURX5za1NMA8uF/fw==", "cpu": [ "x64" ], diff --git a/remote/package.json b/remote/package.json index c375063b2b397..0143b6c5ce3d6 100644 --- a/remote/package.json +++ b/remote/package.json @@ -4,7 +4,7 @@ "private": true, "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.42", - "@github/copilot": "^1.0.28", + "@github/copilot": "^1.0.34", "@github/copilot-sdk": "^0.2.2", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index a352bdd7d61e0..38a33a942186a 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -86,6 +86,7 @@ export interface IProductConfiguration { readonly urlProtocol: string; readonly dataFolderName: string; // location for extensions (e.g. ~/.vscode-insiders) + readonly sharedDataFolderName: string; // location for shared data (e.g. ~/.vscode-insiders-shared) readonly builtInExtensions?: IBuiltInExtension[]; readonly walkthroughMetadata?: IProductWalkthrough[]; diff --git a/src/vs/base/parts/storage/common/storage.ts b/src/vs/base/parts/storage/common/storage.ts index 38ed1e08c93b7..68a46e2f7166b 100644 --- a/src/vs/base/parts/storage/common/storage.ts +++ b/src/vs/base/parts/storage/common/storage.ts @@ -7,7 +7,7 @@ import { ThrottledDelayer } from '../../../common/async.js'; import { Event, PauseableEmitter } from '../../../common/event.js'; import { Disposable, IDisposable } from '../../../common/lifecycle.js'; import { parse, stringify } from '../../../common/marshalling.js'; -import { isObject, isUndefinedOrNull } from '../../../common/types.js'; +import { isObject, isUndefined, isUndefinedOrNull } from '../../../common/types.js'; export enum StorageHint { @@ -434,3 +434,66 @@ export class InMemoryStorageDatabase implements IStorageDatabase { async optimize(): Promise { } async close(): Promise { } } + + +export const MIGRATED_KEY = '__$__migratedStorageMarker'; + +export class MigratingStorage extends Storage { + + private migratedKeys: Set = new Set(); + private fallbackStorage: IStorage | undefined = undefined; + private isFallbackStorageReadonly: boolean = false; + + override async init(): Promise { + await super.init(); + + // Load the set of keys already migrated from fallback + this.migratedKeys = this.loadMigratedKeys(); + } + + public setFallbackStorage(storage: IStorage, isReadonly: boolean): void { + this.fallbackStorage = storage; + this.isFallbackStorageReadonly = isReadonly; + } + + private static readonly INTERNAL_KEY_PREFIX = '__$__'; + + override get(key: string, fallbackValue: string): string; + override get(key: string, fallbackValue?: string): string | undefined; + override get(key: string, fallbackValue?: string): string | undefined { + if (!key.startsWith(MigratingStorage.INTERNAL_KEY_PREFIX) && !this.migratedKeys.has(key) && isUndefined(super.get(key))) { + // Check fallback storage and auto-migrate on hit. + // Mark the key as migrated immediately to prevent + // re-checking the fallback, and to ensure a key + // that was intentionally removed after migration + // is not resurrected from the fallback. + this.migratedKeys.add(key); + const value = this.fallbackStorage?.items.get(key); + if (!isUndefined(value)) { + this.set(key, value); + if (!this.isFallbackStorageReadonly) { + this.fallbackStorage?.delete(key); + } + this.persistMigratedKeys(); + } + } + return super.get(key, fallbackValue); + } + + private loadMigratedKeys(): Set { + const raw = super.get(MIGRATED_KEY); + if (raw) { + try { + return new Set(JSON.parse(raw)); + } catch { + // Fail gracefully + } + } + return new Set(); + } + + private persistMigratedKeys(): void { + this.set(MIGRATED_KEY, JSON.stringify([...this.migratedKeys])); + } +} + diff --git a/src/vs/base/parts/storage/node/storage.ts b/src/vs/base/parts/storage/node/storage.ts index edd322f692bdf..df02aaf08aa81 100644 --- a/src/vs/base/parts/storage/node/storage.ts +++ b/src/vs/base/parts/storage/node/storage.ts @@ -22,6 +22,14 @@ interface IDatabaseConnection { export interface ISQLiteStorageDatabaseOptions { readonly logging?: ISQLiteStorageDatabaseLoggingOptions; + readonly useWAL?: boolean; + + /** + * If set, configures SQLite's busy timeout in milliseconds. + * When another process holds a write lock, SQLite will retry + * for this duration before returning SQLITE_BUSY. + */ + readonly busyTimeout?: number; } export interface ISQLiteStorageDatabaseLoggingOptions { @@ -41,6 +49,8 @@ export class SQLiteStorageDatabase implements IStorageDatabase { private readonly name: string; private readonly logger: SQLiteStorageDatabaseLogger; + private readonly useWAL: boolean; + private readonly busyTimeout: number | undefined; private readonly whenConnected: Promise; @@ -50,6 +60,8 @@ export class SQLiteStorageDatabase implements IStorageDatabase { ) { this.name = basename(this.path); this.logger = new SQLiteStorageDatabaseLogger(options.logging); + this.useWAL = !!options.useWAL; + this.busyTimeout = options.busyTimeout; this.whenConnected = this.connect(this.path); } @@ -326,10 +338,17 @@ export class SQLiteStorageDatabase implements IStorageDatabase { // The following exec() statement serves two purposes: // - create the DB if it does not exist yet // - validate that the DB is not corrupt (the open() call does not throw otherwise) - return this.exec(connection, [ + const pragmas: string[] = [ 'PRAGMA user_version = 1;', - 'CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)' - ].join('')).then(() => { + 'CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB);' + ]; + if (this.useWAL) { + pragmas.push('PRAGMA journal_mode=WAL;'); + } + if (this.busyTimeout) { + pragmas.push(`PRAGMA busy_timeout=${this.busyTimeout};`); + } + return this.exec(connection, pragmas.join('')).then(() => { return resolve(connection); }, error => { return connection.db.close(() => reject(error)); diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 3eb46b8d0f2ba..10e90c5669389 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -1397,7 +1397,7 @@ export class CodeApplication extends Disposable { } // Handle agents window first based on context - if ((process as INodeProcess).isEmbeddedApp || (args['agents'] && this.productService.quality !== 'stable')) { + if ((process as INodeProcess).isEmbeddedApp || (!isLinux && args['agents'] && this.productService.quality !== 'stable')) { return windowsMainService.openAgentsWindow({ context, cli: args, diff --git a/src/vs/code/node/cli.ts b/src/vs/code/node/cli.ts index ac3420e811575..43ea73d798b41 100644 --- a/src/vs/code/node/cli.ts +++ b/src/vs/code/node/cli.ts @@ -248,11 +248,13 @@ export async function main(argv: string[]): Promise { const tempParentDir = randomPath(tmpdir(), 'vscode'); const tempUserDataDir = join(tempParentDir, 'data'); const tempExtensionsDir = join(tempParentDir, 'extensions'); + const tempSharedDataDir = join(tempParentDir, 'shared'); addArg(argv, '--user-data-dir', tempUserDataDir); addArg(argv, '--extensions-dir', tempExtensionsDir); + addArg(argv, '--shared-data-dir', tempSharedDataDir); - console.log(`State is temporarily stored. Relaunch this state with: ${product.applicationName} --user-data-dir "${tempUserDataDir}" --extensions-dir "${tempExtensionsDir}"`); + console.log(`State is temporarily stored. Relaunch this state with: ${product.applicationName} --user-data-dir "${tempUserDataDir}" --extensions-dir "${tempExtensionsDir}" --shared-data-dir "${tempSharedDataDir}"`); } const hasReadStdinArg = args._.some(arg => arg === '-') || args.chat?._.some(arg => arg === '-'); diff --git a/src/vs/editor/standalone/browser/standaloneServices.ts b/src/vs/editor/standalone/browser/standaloneServices.ts index ace6ffc3bfd2a..9821502e4fe23 100644 --- a/src/vs/editor/standalone/browser/standaloneServices.ts +++ b/src/vs/editor/standalone/browser/standaloneServices.ts @@ -223,6 +223,7 @@ class StandaloneEnvironmentService implements IEnvironmentService { readonly argvResource: URI = URI.from({ scheme: 'monaco', authority: 'argvResource' }); readonly untitledWorkspacesHome: URI = URI.from({ scheme: 'monaco', authority: 'untitledWorkspacesHome' }); readonly workspaceStorageHome: URI = URI.from({ scheme: 'monaco', authority: 'workspaceStorageHome' }); + readonly appSharedDataHome: URI = URI.from({ scheme: 'monaco', authority: 'appSharedDataHome' }); readonly localHistoryHome: URI = URI.from({ scheme: 'monaco', authority: 'localHistoryHome' }); readonly cacheHome: URI = URI.from({ scheme: 'monaco', authority: 'cacheHome' }); readonly userDataSyncHome: URI = URI.from({ scheme: 'monaco', authority: 'userDataSyncHome' }); diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 337851c052634..cb0397aaaa42a 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -58,6 +58,10 @@ export interface IActionListItem { readonly group?: { kind?: unknown; icon?: ThemeIcon; title: string }; readonly disabled?: boolean; readonly label?: string; + /** + * Optional detail text displayed as a second line below the label. + */ + readonly detail?: string; readonly description?: string | IMarkdownString; /** * Optional hover configuration shown when focusing/hovering over the item. @@ -111,8 +115,10 @@ interface IActionMenuTemplateData { readonly container: HTMLElement; readonly icon: HTMLElement; readonly text: HTMLElement; + readonly detail: HTMLElement; readonly badge: HTMLElement; readonly description?: HTMLElement; + readonly groupTitle: HTMLElement; readonly keybinding: KeybindingLabel; readonly toolbar: HTMLElement; readonly submenuIndicator: HTMLElement; @@ -189,6 +195,7 @@ class ActionItemRenderer implements IListRenderer, IAction private readonly _onRemoveItem: ((item: IActionListItem) => void) | undefined, private readonly _onShowSubmenu: ((item: IActionListItem) => void) | undefined, private readonly _hasAnySubmenuActions: boolean, + private readonly _groupTitleByIndex: ReadonlyMap, private readonly _linkHandler: ((uri: URI, item: IActionListItem) => void) | undefined, @IKeybindingService private readonly _keybindingService: IKeybindingService, @IOpenerService private readonly _openerService: IOpenerService, @@ -213,6 +220,14 @@ class ActionItemRenderer implements IListRenderer, IAction description.className = 'description'; container.append(description); + const groupTitle = document.createElement('span'); + groupTitle.className = 'group-title'; + container.append(groupTitle); + + const detail = document.createElement('span'); + detail.className = 'detail'; + container.append(detail); + const keybinding = new KeybindingLabel(container, OS); const toolbar = document.createElement('div'); @@ -225,7 +240,7 @@ class ActionItemRenderer implements IListRenderer, IAction const elementDisposables = new DisposableStore(); - return { container, icon, text, badge, description, keybinding, toolbar, submenuIndicator, elementDisposables }; + return { container, icon, text, detail, badge, description, groupTitle, keybinding, toolbar, submenuIndicator, elementDisposables }; } renderElement(element: IActionListItem, _index: number, data: IActionMenuTemplateData): void { @@ -306,6 +321,25 @@ class ActionItemRenderer implements IListRenderer, IAction data.description!.style.display = 'none'; } + // Render group title (shown to the right, separate from description) + const groupTitleText = this._groupTitleByIndex.get(_index); + if (groupTitleText) { + data.groupTitle.textContent = groupTitleText; + data.groupTitle.style.display = ''; + } else { + data.groupTitle.textContent = ''; + data.groupTitle.style.display = 'none'; + } + + // Render optional detail (shown as second line below the label) + if (element.detail) { + data.detail.textContent = stripNewlines(element.detail); + data.detail.style.display = ''; + } else { + data.detail.textContent = ''; + data.detail.style.display = 'none'; + } + const actionTitle = this._keybindingService.lookupKeybinding(acceptSelectedActionCommand)?.getLabel(); const previewTitle = this._keybindingService.lookupKeybinding(previewSelectedActionCommand)?.getLabel(); data.container.classList.toggle('option-disabled', !!element.disabled); @@ -431,10 +465,16 @@ export interface IActionListOptions { readonly onDidToggleSection?: (section: string, collapsed: boolean) => void; /** - * When true, descriptions are rendered as subtext below the title - * instead of inline to the right. + * When true, descriptions are rendered inline right after the label + * instead of aligned to the right. */ - readonly descriptionBelow?: boolean; + readonly inlineDescription?: boolean; + + /** + * When true, the group title is shown on the first item of each group + * in the description area (aligned to the right). + */ + readonly showGroupTitleOnFirstItem?: boolean; @@ -462,7 +502,6 @@ export class ActionListWidget extends Disposable { private readonly _list: List>; protected readonly _actionLineHeight: number; - private readonly _baseLineHeight = 24; protected readonly _headerLineHeight = 24; protected readonly _separatorLineHeight = 8; @@ -484,6 +523,7 @@ export class ActionListWidget extends Disposable { private readonly _filterInput: HTMLInputElement | undefined; private readonly _filterContainer: HTMLElement | undefined; private readonly _filterCts = this._register(new MutableDisposable()); + private readonly _groupTitleByIndex = new Map(); private readonly _onDidRequestLayout = this._register(new Emitter()); @@ -507,10 +547,10 @@ export class ActionListWidget extends Disposable { super(); this.domNode = document.createElement('div'); this.domNode.classList.add('actionList'); - if (this._options?.descriptionBelow) { - this.domNode.classList.add('description-below'); + if (this._options?.inlineDescription) { + this.domNode.classList.add('inline-description'); } - this._actionLineHeight = this._options?.descriptionBelow ? 48 : 24; + this._actionLineHeight = 24; // Create submenu container appended to domNode this._submenuContainer = document.createElement('div'); @@ -548,7 +588,7 @@ export class ActionListWidget extends Disposable { const hasAnySubmenuActions = reserveSubmenuSpace && items.some(item => !!item.submenuActions?.length && !item.hover?.content); this._list = this._register(new List(user, this.domNode, virtualDelegate, [ - new ActionItemRenderer(preview, (item) => this._removeItem(item), (item) => this._showSubmenuForItem(item), hasAnySubmenuActions, this._options?.linkHandler, this._keybindingService, this._openerService), + new ActionItemRenderer(preview, (item) => this._removeItem(item), (item) => this._showSubmenuForItem(item), hasAnySubmenuActions, this._groupTitleByIndex, this._options?.linkHandler, this._keybindingService, this._openerService), new HeaderRenderer(), new SeparatorRenderer(), ], { @@ -559,10 +599,16 @@ export class ActionListWidget extends Disposable { getAriaLabel: element => { if (element.kind === ActionListItemKind.Action) { let label = element.label ? stripNewlines(element?.label) : ''; + if (element.detail) { + label = label + ', ' + stripNewlines(element.detail); + } if (element.description) { const descText = typeof element.description === 'string' ? element.description : element.description.value; label = label + ', ' + stripNewlines(descText); } + if (element.group?.title) { + label = label + ', ' + element.group.title; + } if (element.disabled) { label = localize({ key: 'customQuickFixWidget.labels', comment: [`Action widget labels for accessibility.`] }, "{0}, Disabled Reason: {1}", label, element.disabled); } @@ -763,6 +809,24 @@ export class ActionListWidget extends Disposable { } } + // Remove orphaned separators (leading, trailing, or consecutive) + for (let i = visible.length - 1; i >= 0; i--) { + if (visible[i].kind !== ActionListItemKind.Separator) { + continue; + } + const isLeading = !visible.slice(0, i).some(v => v.kind === ActionListItemKind.Action); + const isTrailing = i === visible.length - 1; + const isConsecutive = i < visible.length - 1 && visible[i + 1].kind === ActionListItemKind.Separator; + if (isLeading || isTrailing || isConsecutive) { + visible.splice(i, 1); + } + } + + // Recompute group title positions based on visible items + if (this._options?.showGroupTitleOnFirstItem) { + this._recomputeGroupTitles(visible); + } + // Capture whether the filter input currently has focus before splice // which may cause DOM changes that shift focus. const filterInputHasFocus = this._filterInput && dom.isActiveElement(this._filterInput); @@ -891,8 +955,8 @@ export class ActionListWidget extends Disposable { } /** - * Returns the height for an action item, using the base line height - * for items without a description when `descriptionBelow` is enabled. + * Returns the height for an action item, using a taller line height + * for items with a detail (second line). */ protected _getItemHeight(item: IActionListItem): number { switch (item.kind) { @@ -901,10 +965,7 @@ export class ActionListWidget extends Disposable { case ActionListItemKind.Separator: return this._separatorLineHeight; default: - if (this._options?.descriptionBelow && !item.description) { - return this._baseLineHeight; - } - return this._actionLineHeight; + return item.detail ? 48 : this._actionLineHeight; } } @@ -1182,6 +1243,18 @@ export class ActionListWidget extends Disposable { } } + private _recomputeGroupTitles(items: readonly IActionListItem[]): void { + this._groupTitleByIndex.clear(); + const seenTitles = new Set(); + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.kind === ActionListItemKind.Action && item.group?.title && !seenTitles.has(item.group.title)) { + seenTitles.add(item.group.title); + this._groupTitleByIndex.set(i, item.group.title); + } + } + } + private _computeToolbarWidth(item: IActionListItem): number { let actionCount = item.toolbarActions?.length ?? 0; if (item.onRemove) { diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index 357a57f67bbd2..ec8b5e9d65da7 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -149,6 +149,19 @@ text-overflow: ellipsis; } +.action-widget .monaco-list-row.action .detail { + order: 99; + width: 100%; + padding-left: 20px; + font-size: 11px; + line-height: 14px; + color: var(--vscode-descriptionForeground); + opacity: 0.8; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .action-widget .monaco-list-row.action .action-item-badge { padding: 0px 6px; border-radius: 10px; @@ -217,31 +230,42 @@ font-size: 12px; } -/* Description below mode — shows descriptions as subtext under the title */ -.action-widget .description-below .monaco-list .monaco-list-row.action { - flex-wrap: wrap; - align-content: center; - padding-right: 2px; +.action-widget .monaco-list-row.action .group-title { + color: var(--vscode-descriptionForeground); + margin-left: 0.5em; + font-size: 12px; + flex-shrink: 0; +} - &:has(.description:not([style*="display: none"])) { +/* Items with detail — show detail as subtext below the title */ +.action-widget .monaco-list .monaco-list-row.action { + &:has(.detail:not([style*="display: none"])) { + flex-wrap: wrap; + align-content: center; padding-top: 6px; + padding-right: 2px; + + .title { + line-height: 14px; + } } +} +/* Inline description mode — description rendered right after the label */ +.action-widget .inline-description .monaco-list-row.action { .title { - line-height: 14px; + flex: initial; + flex-shrink: 1; + min-width: 0; } .description { - display: block; - width: 100%; - margin-left: 0; - padding-left: 20px; - font-size: 11px; - line-height: 14px; - opacity: 0.8; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + flex: 1; + min-width: 0; + } + + .action-list-item-toolbar { + margin-left: auto; } } @@ -279,7 +303,7 @@ } .action-widget .action-list-filter:first-child { - border-bottom: 1px solid var(--vscode-editorHoverWidget-border); + border-bottom: none; } .action-widget .action-list-filter:last-child { diff --git a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts index 87fd04735db76..5adc5dde9d3b5 100644 --- a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts +++ b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts @@ -19,6 +19,10 @@ export interface IActionWidgetDropdownAction extends IAction { category?: { label: string; order: number; showHeader?: boolean }; icon?: ThemeIcon; description?: string; + /** + * Optional detail text displayed as a second line below the label. + */ + detail?: string; /** * Optional flyout hover configuration shown when focusing/hovering over the action. */ @@ -138,6 +142,7 @@ export class ActionWidgetDropdown extends BaseDropdown { item: action, tooltip: action.tooltip, description: action.description, + detail: action.detail, hover: action.hover, toolbarActions: action.toolbarActions, kind: ActionListItemKind.Action, diff --git a/src/vs/platform/agentHost/browser/nullAgentHostService.ts b/src/vs/platform/agentHost/browser/nullAgentHostService.ts index 018ecf07db64b..6ee1419de5dec 100644 --- a/src/vs/platform/agentHost/browser/nullAgentHostService.ts +++ b/src/vs/platform/agentHost/browser/nullAgentHostService.ts @@ -7,12 +7,12 @@ import { Event } from '../../../base/common/event.js'; import { IReference } from '../../../base/common/lifecycle.js'; import { constObservable, IObservable } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; -import type { IAgentCreateSessionConfig, IAgentHostService, IAgentHostSocketInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult } from '../common/agentService.js'; +import type { IAgentCreateSessionConfig, IAgentHostService, IAgentHostSocketInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../common/agentService.js'; import type { IAgentSubscription } from '../common/state/agentSubscription.js'; -import type { ICreateTerminalParams, IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; -import type { IActionEnvelope, INotification, ISessionAction, ITerminalAction } from '../common/state/sessionActions.js'; -import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult } from '../common/state/sessionProtocol.js'; -import type { ComponentToState, IRootState, StateComponents } from '../common/state/sessionState.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 { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult } from '../common/state/sessionProtocol.js'; +import type { ComponentToState, RootState, StateComponents } from '../common/state/sessionState.js'; const notSupported = () => { throw new Error('Local agent host is not supported in the browser.'); }; @@ -27,31 +27,31 @@ export class NullAgentHostService implements IAgentHostService { readonly onAgentHostExit = Event.None; readonly onAgentHostStart = Event.None; readonly onDidNotification: Event = Event.None; - readonly onDidAction: Event = Event.None; + readonly onDidAction: Event = Event.None; readonly authenticationPending: IObservable = constObservable(false); setAuthenticationPending(_pending: boolean): void { /* no-op */ } - get rootState(): IAgentSubscription { return notSupported(); } + get rootState(): IAgentSubscription { return notSupported(); } getSubscription(_kind: T, _resource: URI): IReference> { return notSupported(); } getSubscriptionUnmanaged(_kind: T, _resource: URI): IAgentSubscription | undefined { return undefined; } - dispatch(_action: ISessionAction | ITerminalAction): void { notSupported(); } + dispatch(_action: SessionAction | TerminalAction): void { notSupported(); } async restartAgentHost(): Promise { notSupported(); } - async authenticate(_params: IAuthenticateParams): Promise { return notSupported(); } + async authenticate(_params: AuthenticateParams): Promise { return notSupported(); } async listSessions(): Promise { return []; } async createSession(_config?: IAgentCreateSessionConfig): Promise { return notSupported(); } - async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise { return notSupported(); } - async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { return notSupported(); } + async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise { return notSupported(); } + async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { return notSupported(); } async startWebSocketServer(): Promise { return notSupported(); } async disposeSession(_session: URI): Promise { } - async createTerminal(_params: ICreateTerminalParams): Promise { notSupported(); } + async createTerminal(_params: CreateTerminalParams): Promise { notSupported(); } async disposeTerminal(_terminal: URI): Promise { } - async resourceList(_uri: URI): Promise { return notSupported(); } - async resourceRead(_uri: URI): Promise { return notSupported(); } - async resourceWrite(_params: IResourceWriteParams): Promise { return notSupported(); } - async resourceCopy(_params: IResourceCopyParams): Promise { return notSupported(); } - async resourceDelete(_params: IResourceDeleteParams): Promise { return notSupported(); } - async resourceMove(_params: IResourceMoveParams): Promise { return notSupported(); } + async resourceList(_uri: URI): Promise { return notSupported(); } + async resourceRead(_uri: URI): Promise { return notSupported(); } + async resourceWrite(_params: ResourceWriteParams): Promise { return notSupported(); } + async resourceCopy(_params: ResourceCopyParams): Promise { return notSupported(); } + async resourceDelete(_params: ResourceDeleteParams): Promise { return notSupported(); } + async resourceMove(_params: ResourceMoveParams): Promise { return notSupported(); } } diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index e7fa4b6fcd1af..8198fcf8c1c0d 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -16,17 +16,17 @@ import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { ILogService } from '../../log/common/log.js'; import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCode } from '../../files/common/files.js'; -import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult } from '../common/agentService.js'; +import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../common/agentService.js'; import { AgentSubscriptionManager, type IAgentSubscription } from '../common/state/agentSubscription.js'; import { agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../common/agentHostUri.js'; -import type { IClientNotificationMap, ICommandMap, IJsonRpcErrorResponse, IJsonRpcRequest } from '../common/state/protocol/messages.js'; -import type { IActionEnvelope, INotification, ISessionAction, ITerminalAction } from '../common/state/sessionActions.js'; -import { ISessionSummary, ROOT_STATE_URI, StateComponents, type IRootState } from '../common/state/sessionState.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 { SessionSummary, ROOT_STATE_URI, StateComponents, type RootState } from '../common/state/sessionState.js'; import { PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; -import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, type IProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; +import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { isClientTransport, type IProtocolTransport } from '../common/state/sessionTransport.js'; import { AhpErrorCodes } from '../common/state/protocol/errors.js'; -import { ContentEncoding, type ICreateTerminalParams, type IResolveSessionConfigResult, type ISessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; +import { ContentEncoding, type CreateTerminalParams, type ResolveSessionConfigResult, type SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; const AHP_CLIENT_CONNECTION_CLOSED = -32000; @@ -36,7 +36,7 @@ export class RemoteAgentHostProtocolError extends Error { readonly code: number; readonly data: unknown | undefined; - constructor(error: IJsonRpcErrorResponse['error']) { + constructor(error: JsonRpcErrorResponse['error']) { super(error.message); this.code = error.code; this.data = error.data; @@ -76,7 +76,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC private _defaultDirectory: string | undefined; private readonly _subscriptionManager: AgentSubscriptionManager; - private readonly _onDidAction = this._register(new Emitter()); + private readonly _onDidAction = this._register(new Emitter()); readonly onDidAction = this._onDidAction.event; private readonly _onDidNotification = this._register(new Emitter()); @@ -154,7 +154,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC // Hydrate root state from the initial snapshot for (const snapshot of result.snapshots ?? []) { if (snapshot.resource === ROOT_STATE_URI) { - this._subscriptionManager.handleRootSnapshot(snapshot.state as IRootState, snapshot.fromSeq); + this._subscriptionManager.handleRootSnapshot(snapshot.state as RootState, snapshot.fromSeq); } } @@ -170,7 +170,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC // ---- IAgentConnection subscription API ---------------------------------- - get rootState(): IAgentSubscription { + get rootState(): IAgentSubscription { return this._subscriptionManager.rootState; } @@ -182,7 +182,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC return this._subscriptionManager.getSubscriptionUnmanaged(resource); } - dispatch(action: ISessionAction | ITerminalAction): void { + dispatch(action: 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: ISessionAction | ITerminalAction, _clientId: string, clientSeq: number): void { + dispatchAction(action: SessionAction | TerminalAction, _clientId: string, clientSeq: number): void { this._sendNotification('dispatchAction', { clientSeq, action }); } @@ -229,7 +229,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC return session; } - async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { + async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { return this._sendRequest('resolveSessionConfig', { provider: params.provider, workingDirectory: params.workingDirectory ? fromAgentHostUri(params.workingDirectory).toString() : undefined, @@ -237,7 +237,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC }); } - async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { + async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { return this._sendRequest('sessionConfigCompletions', { provider: params.provider, workingDirectory: params.workingDirectory ? fromAgentHostUri(params.workingDirectory).toString() : undefined, @@ -250,7 +250,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC /** * Authenticate with the remote agent host using a specific scheme. */ - async authenticate(params: IAuthenticateParams): Promise { + async authenticate(params: AuthenticateParams): Promise { await this._sendRequest('authenticate', params); return { authenticated: true }; } @@ -272,7 +272,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC /** * Create a new terminal on the remote agent host. */ - async createTerminal(params: ICreateTerminalParams): Promise { + async createTerminal(params: CreateTerminalParams): Promise { await this._sendRequest('createTerminal', params); } @@ -288,7 +288,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC */ async listSessions(): Promise { const result = await this._sendRequest('listSessions', {}); - return result.items.map((s: ISessionSummary) => ({ + return result.items.map((s: SessionSummary) => ({ session: URI.parse(s.resource), startTime: s.createdAt, modifiedTime: s.modifiedAt, @@ -314,34 +314,34 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC /** * List the contents of a directory on the remote host's filesystem. */ - async resourceList(uri: URI): Promise { + async resourceList(uri: URI): Promise { return await this._sendRequest('resourceList', { uri: uri.toString() }); } /** * Read the content of a resource on the remote host. */ - async resourceRead(uri: URI): Promise { + async resourceRead(uri: URI): Promise { return this._sendRequest('resourceRead', { uri: uri.toString() }); } - async resourceWrite(params: ICommandMap['resourceWrite']['params']): Promise { + async resourceWrite(params: CommandMap['resourceWrite']['params']): Promise { return this._sendRequest('resourceWrite', params); } - async resourceCopy(params: ICommandMap['resourceCopy']['params']): Promise { + async resourceCopy(params: CommandMap['resourceCopy']['params']): Promise { return this._sendRequest('resourceCopy', params); } - async resourceDelete(params: ICommandMap['resourceDelete']['params']): Promise { + async resourceDelete(params: CommandMap['resourceDelete']['params']): Promise { return this._sendRequest('resourceDelete', params); } - async resourceMove(params: ICommandMap['resourceMove']['params']): Promise { + async resourceMove(params: CommandMap['resourceMove']['params']): Promise { return this._sendRequest('resourceMove', params); } - private _handleMessage(msg: IProtocolMessage): void { + private _handleMessage(msg: ProtocolMessage): void { if (isJsonRpcRequest(msg)) { this._handleReverseRequest(msg.id, msg.method, msg.params); } else if (isJsonRpcResponse(msg)) { @@ -472,14 +472,14 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC } /** Send a typed JSON-RPC notification for a protocol-defined method. */ - private _sendNotification(method: M, params: IClientNotificationMap[M]['params']): void { - // Generic M can't satisfy the distributive IAhpNotification union directly + private _sendNotification(method: M, params: ClientNotificationMap[M]['params']): void { + // Generic M can't satisfy the distributive AhpNotification union directly // eslint-disable-next-line local/code-no-dangerous-type-assertions - this._transport.send({ jsonrpc: '2.0' as const, method, params } as IProtocolMessage); + this._transport.send({ jsonrpc: '2.0' as const, method, params } as ProtocolMessage); } /** Send a typed JSON-RPC request for a protocol-defined method. */ - private _sendRequest(method: M, params: ICommandMap[M]['params']): Promise { + private _sendRequest(method: M, params: CommandMap[M]['params']): Promise { if (this._closeError) { return Promise.reject(this._closeError); } @@ -487,10 +487,10 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC const id = this._nextRequestId++; const deferred = new DeferredPromise(); this._pendingRequests.set(id, deferred); - // Generic M can't satisfy the distributive IAhpRequest union directly + // Generic M can't satisfy the distributive AhpRequest union directly // eslint-disable-next-line local/code-no-dangerous-type-assertions - this._transport.send({ jsonrpc: '2.0' as const, id, method, params } as IProtocolMessage); - return deferred.p as Promise; + this._transport.send({ jsonrpc: '2.0' as const, id, method, params } as ProtocolMessage); + return deferred.p as Promise; } /** Send a JSON-RPC request for a VS Code extension method (not in the protocol spec). */ @@ -502,12 +502,12 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC const id = this._nextRequestId++; const deferred = new DeferredPromise(); this._pendingRequests.set(id, deferred); - const request: IJsonRpcRequest = { jsonrpc: '2.0', id, method, params }; + const request: JsonRpcRequest = { jsonrpc: '2.0', id, method, params }; this._transport.send(request); return deferred.p as Promise; } - private _toProtocolError(error: IJsonRpcErrorResponse['error']): RemoteAgentHostProtocolError { + private _toProtocolError(error: JsonRpcErrorResponse['error']): RemoteAgentHostProtocolError { return new RemoteAgentHostProtocolError(error); } diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts index f14d1b42b602c..3070a1744814d 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts @@ -8,7 +8,7 @@ // and maintains connections, reconnecting as the setting changes. import { Emitter } from '../../../base/common/event.js'; -import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { DeferredPromise, raceTimeout } from '../../../base/common/async.js'; import { ConfigurationTarget, IConfigurationService } from '../../configuration/common/configuration.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; @@ -59,7 +59,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo private readonly _tokens = new Map(); /** * Stores the original {@link IRemoteAgentHostEntry} for connections - * registered via {@link addSSHConnection}. This is needed because + * registered via {@link addManagedConnection}. This is needed because * tunnel entries are not persisted to settings and therefore don't * appear in {@link configuredEntries}. */ @@ -206,7 +206,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo return connection; } - async addSSHConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection): Promise { + async addManagedConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection, transportDisposable?: IDisposable): Promise { const address = getEntryAddress(entry); // Dispose any existing entry for this address to avoid leaking @@ -222,6 +222,12 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo // Create a connection entry wrapping the pre-connected client const protocolClient = connection as RemoteAgentHostProtocolClient; store.add(protocolClient); + // Tear the underlying transport (e.g. SSH/tunnel relay) down with + // the entry. This is what makes "Remove Remote" actually close the + // shared-process tunnel and stop the remote agent host process. + if (transportDisposable) { + store.add(transportDisposable); + } const connEntry: IConnectionEntry = { store, client: protocolClient, connected: true, status: RemoteAgentHostConnectionStatus.Connected }; this._entries.set(address, connEntry); this._names.set(address, entry.name); diff --git a/src/vs/platform/agentHost/browser/webSocketClientTransport.ts b/src/vs/platform/agentHost/browser/webSocketClientTransport.ts index 556e6bd342cb5..6d01fe3e41614 100644 --- a/src/vs/platform/agentHost/browser/webSocketClientTransport.ts +++ b/src/vs/platform/agentHost/browser/webSocketClientTransport.ts @@ -9,7 +9,7 @@ import { Emitter } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { connectionTokenQueryName } from '../../../base/common/network.js'; -import type { IAhpServerNotification, IJsonRpcResponse, IProtocolMessage } from '../common/state/sessionProtocol.js'; +import type { AhpServerNotification, JsonRpcResponse, ProtocolMessage } from '../common/state/sessionProtocol.js'; import type { IClientTransport } from '../common/state/sessionTransport.js'; import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../common/transportConstants.js'; @@ -22,7 +22,7 @@ import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from */ export class WebSocketClientTransport extends Disposable implements IClientTransport { - private readonly _onMessage = this._register(new Emitter()); + private readonly _onMessage = this._register(new Emitter()); readonly onMessage = this._onMessage.event; private readonly _onClose = this._register(new Emitter()); @@ -114,9 +114,9 @@ export class WebSocketClientTransport extends Disposable implements IClientTrans return; } const text = event.data; - let message: IProtocolMessage; + let message: ProtocolMessage; try { - message = JSON.parse(text) as IProtocolMessage; + message = JSON.parse(text) as ProtocolMessage; } catch (err) { this._malformedFrames++; if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) { @@ -148,7 +148,7 @@ export class WebSocketClientTransport extends Disposable implements IClientTrans }); } - send(message: IProtocolMessage | IAhpServerNotification | IJsonRpcResponse): void { + send(message: ProtocolMessage | AhpServerNotification | JsonRpcResponse): void { if (this._ws?.readyState === WebSocket.OPEN) { this._ws.send(JSON.stringify(message)); } diff --git a/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts b/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts index 844d4c02c4177..62756371249b2 100644 --- a/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts +++ b/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts @@ -11,7 +11,7 @@ import { URI } from '../../../base/common/uri.js'; import { createFileSystemProviderError, FilePermission, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileChange, IFileDeleteOptions, IFileOverwriteOptions, IFileSystemProvider, IFileWriteOptions, IStat } from '../../files/common/files.js'; import { fromAgentHostUri, toAgentHostUri } from './agentHostUri.js'; import { type IAgentConnection } from './agentService.js'; -import { ContentEncoding, type IDirectoryEntry, type IResourceDeleteParams, type IResourceDeleteResult, type IResourceListResult, type IResourceMoveParams, type IResourceMoveResult, type IResourceReadResult, type IResourceWriteParams, type IResourceWriteResult } from './state/protocol/commands.js'; +import { ContentEncoding, type DirectoryEntry, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceWriteParams, type ResourceWriteResult } from './state/protocol/commands.js'; /** * Interface for performing resource operations on a remote endpoint. @@ -20,11 +20,11 @@ import { ContentEncoding, type IDirectoryEntry, type IResourceDeleteParams, type * filesystems (server→client) satisfy this contract. */ export interface IRemoteFilesystemConnection { - resourceList(uri: URI): Promise; - resourceRead(uri: URI): Promise; - resourceWrite(params: IResourceWriteParams): Promise; - resourceDelete(params: IResourceDeleteParams): Promise; - resourceMove(params: IResourceMoveParams): Promise; + resourceList(uri: URI): Promise; + resourceRead(uri: URI): Promise; + resourceWrite(params: ResourceWriteParams): Promise; + resourceDelete(params: ResourceDeleteParams): Promise; + resourceMove(params: ResourceMoveParams): Promise; } /** @@ -197,7 +197,7 @@ export abstract class AHPFileSystemProvider extends Disposable implements IFileS return connection; } - private async _listDirectory(authority: string, resource: URI): Promise { + private async _listDirectory(authority: string, resource: URI): Promise { const connection = this._getConnection(authority); try { const originalUri = this._decodeUri(resource); diff --git a/src/vs/platform/agentHost/common/agentPluginManager.ts b/src/vs/platform/agentHost/common/agentPluginManager.ts index 4970318c22ea6..035c530f986b2 100644 --- a/src/vs/platform/agentHost/common/agentPluginManager.ts +++ b/src/vs/platform/agentHost/common/agentPluginManager.ts @@ -5,7 +5,7 @@ import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; -import type { ICustomizationRef, ISessionCustomization } from './state/sessionState.js'; +import type { CustomizationRef, SessionCustomization } from './state/sessionState.js'; export const IAgentPluginManager = createDecorator('agentPluginManager'); @@ -14,7 +14,7 @@ export const IAgentPluginManager = createDecorator('agentPl */ export interface ISyncedCustomization { /** The session customization with loading/error status. */ - readonly customization: ISessionCustomization; + readonly customization: SessionCustomization; /** Local plugin directory URI, defined when the sync was successful. */ readonly pluginDir?: URI; } @@ -43,5 +43,5 @@ export interface IAgentPluginManager { * @returns Final status for every customization, with `pluginDir` * defined when the sync was successful. */ - syncCustomizations(clientId: string, customizations: ICustomizationRef[], progress?: (status: ISessionCustomization[]) => void): Promise; + syncCustomizations(clientId: string, customizations: CustomizationRef[], progress?: (status: SessionCustomization[]) => void): Promise; } diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 60414285514d7..b480a047f7909 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -11,11 +11,11 @@ import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { ISyncedCustomization } from './agentPluginManager.js'; import type { IAgentSubscription } from './state/agentSubscription.js'; -import type { ICreateTerminalParams, IResolveSessionConfigResult, ISessionConfigCompletionsResult } from './state/protocol/commands.js'; -import { IProtectedResourceMetadata, type IConfigSchema, type IFileEdit, type IModelSelection, type ISessionActiveClient, type IToolDefinition } from './state/protocol/state.js'; -import type { IActionEnvelope, INotification, ISessionAction, ITerminalAction } from './state/sessionActions.js'; -import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IStateSnapshot } from './state/sessionProtocol.js'; -import { AttachmentType, ComponentToState, SessionInputResponseKind, SessionStatus, StateComponents, type ICustomizationRef, type IPendingMessage, type IRootState, type ISessionInputAnswer, type ISessionInputRequest, type IToolCallResult, type IToolResultContent, type PolicyState, type StringOrMarkdown } from './state/sessionState.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 { 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'; // IPC contract between the renderer and the agent host utility process. // Defines all serializable event types, the IAgent provider interface, @@ -66,11 +66,11 @@ export interface IAgentSessionMetadata { readonly project?: IAgentSessionProjectInfo; readonly summary?: string; readonly status?: SessionStatus; - readonly model?: IModelSelection; + readonly model?: ModelSelection; readonly workingDirectory?: URI; readonly isRead?: boolean; readonly isDone?: boolean; - readonly diffs?: readonly IFileEdit[]; + readonly diffs?: readonly FileEdit[]; } export interface IAgentSessionProjectInfo { @@ -100,7 +100,7 @@ export interface IAgentDescriptor { * Parameters for the `authenticate` command. * Analogous to sending `Authorization: Bearer ` (RFC 6750 section 2.1). */ -export interface IAuthenticateParams { +export interface AuthenticateParams { /** * The `resource` identifier from the server's * {@link IAuthorizationProtectedResourceMetadata} that this token targets. @@ -114,17 +114,17 @@ export interface IAuthenticateParams { /** * Result of the `authenticate` command. */ -export interface IAuthenticateResult { +export interface AuthenticateResult { /** Whether the token was accepted. */ readonly authenticated: boolean; } export interface IAgentCreateSessionConfig { readonly provider?: AgentProvider; - readonly model?: IModelSelection; + readonly model?: ModelSelection; readonly session?: URI; readonly workingDirectory?: URI; - readonly config?: Record; + readonly config?: Record; /** * Eagerly claim the active client role for the new session. When provided, * the server initializes the session with this client as the active @@ -132,7 +132,7 @@ export interface IAgentCreateSessionConfig { * action immediately after creation. The `clientId` MUST match the * connection's own `clientId`. */ - readonly activeClient?: ISessionActiveClient; + readonly activeClient?: SessionActiveClient; /** Fork from an existing session at a specific turn. */ readonly fork?: { readonly session: URI; @@ -153,7 +153,7 @@ export const AgentHostSessionConfigBranchNameHintKey = 'branchNameHint'; export interface IAgentResolveSessionConfigParams { readonly provider?: AgentProvider; readonly workingDirectory?: URI; - readonly config?: Record; + readonly config?: Record; } export interface IAgentSessionConfigCompletionsParams extends IAgentResolveSessionConfigParams { @@ -180,9 +180,9 @@ export interface IAgentModelInfo { readonly provider: AgentProvider; readonly id: string; readonly name: string; - readonly maxContextWindow: number; + readonly maxContextWindow?: number; readonly supportsVision: boolean; - readonly configSchema?: IConfigSchema; + readonly configSchema?: ConfigSchema; readonly policyState?: PolicyState; } @@ -268,8 +268,8 @@ export interface IAgentToolStartEvent extends IAgentProgressEventBase { export interface IAgentToolCompleteEvent extends IAgentProgressEventBase { readonly type: 'tool_complete'; readonly toolCallId: string; - /** Tool execution result, matching the protocol {@link IToolCallResult} shape. */ - readonly result: IToolCallResult; + /** Tool execution result, matching the protocol {@link ToolCallResult} shape. */ + readonly result: ToolCallResult; readonly isUserRequested?: boolean; /** Serialized JSON of tool-specific telemetry data. */ readonly toolTelemetry?: string; @@ -317,7 +317,7 @@ export interface IAgentToolReadyEvent extends IAgentProgressEventBase { /** File path associated with the permission request. */ readonly permissionPath?: string; /** File edits this tool call will perform, for preview before confirmation. */ - readonly edits?: { items: IFileEdit[] }; + readonly edits?: { items: FileEdit[] }; } /** Streaming reasoning/thinking content from the assistant. */ @@ -335,7 +335,7 @@ export interface IAgentSteeringConsumedEvent extends IAgentProgressEventBase { /** The agent's ask_user tool is requesting user input. */ export interface IAgentUserInputRequestEvent extends IAgentProgressEventBase { readonly type: 'user_input_request'; - readonly request: ISessionInputRequest; + readonly request: SessionInputRequest; } /** A subagent has been spawned by a tool call. */ @@ -351,7 +351,7 @@ export interface IAgentSubagentStartedEvent extends IAgentProgressEventBase { export interface IAgentToolContentChangedEvent extends IAgentProgressEventBase { readonly type: 'tool_content_changed'; readonly toolCallId: string; - readonly content: IToolResultContent[]; + readonly content: ToolResultContent[]; } export type IAgentProgressEvent = @@ -419,10 +419,10 @@ export interface IAgent { createSession(config?: IAgentCreateSessionConfig): Promise; /** Resolve the dynamic configuration schema for creating a session. */ - resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise; + resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise; /** Return dynamic completions for a session configuration property. */ - sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise; + sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise; /** Send a user message into an existing session. */ sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[], turnId?: string): Promise; @@ -435,7 +435,7 @@ export interface IAgent { * Queued messages are consumed on the server side and are not * forwarded to the agent; `queuedMessages` will always be empty. */ - setPendingMessages?(session: URI, steeringMessage: IPendingMessage | undefined, queuedMessages: readonly IPendingMessage[]): void; + setPendingMessages?(session: URI, steeringMessage: PendingMessage | undefined, queuedMessages: readonly PendingMessage[]): void; /** Retrieve all session events/messages for reconstruction. */ getSessionMessages(session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent | IAgentSubagentStartedEvent)[]>; @@ -447,13 +447,13 @@ export interface IAgent { abortSession(session: URI): Promise; /** Change the model for an existing session. */ - changeModel(session: URI, model: IModelSelection): Promise; + changeModel(session: URI, model: ModelSelection): Promise; /** Respond to a pending permission request from the SDK. */ respondToPermissionRequest(requestId: string, approved: boolean): void; /** Respond to a pending user input request from the SDK's ask_user tool. */ - respondToUserInputRequest(requestId: string, response: SessionInputResponseKind, answers?: Record): void; + respondToUserInputRequest(requestId: string, response: SessionInputResponseKind, answers?: Record): void; /** Return the descriptor for this agent. */ getDescriptor(): IAgentDescriptor; @@ -465,7 +465,7 @@ export interface IAgent { listSessions(): Promise; /** Declare protected resources this agent requires auth for (RFC 9728). */ - getProtectedResources(): IProtectedResourceMetadata[]; + getProtectedResources(): ProtectedResourceMetadata[]; /** * Authenticate for a specific resource. Returns true if accepted. @@ -487,7 +487,7 @@ export interface IAgent { * * The agent MAY defer a client restart until all active sessions are idle. */ - setClientCustomizations(clientId: string, customizations: ICustomizationRef[], progress?: (results: ISyncedCustomization[]) => void): Promise; + setClientCustomizations(clientId: string, customizations: CustomizationRef[], progress?: (results: ISyncedCustomization[]) => void): Promise; /** * Receives client-provided tool definitions to make available in a @@ -501,7 +501,7 @@ export interface IAgent { * @param clientId The client that owns these tools. * @param tools The tool definitions (full replacement). */ - setClientTools(session: URI, clientId: string, tools: IToolDefinition[]): void; + setClientTools(session: URI, clientId: string, tools: ToolDefinition[]): void; /** * Called when a client completes a client-provided tool call. @@ -509,7 +509,7 @@ export interface IAgent { * * @param session The session the tool call belongs to. */ - onClientToolCallComplete(session: URI, toolCallId: string, result: IToolCallResult): void; + onClientToolCallComplete(session: URI, toolCallId: string, result: ToolCallResult): void; /** * Notifies the agent that a customization has been toggled on or off. @@ -541,11 +541,11 @@ export interface IAgentService { /** * Authenticate for a protected resource on the server. - * The {@link IAuthenticateParams.resource} must match a resource from + * The {@link AuthenticateParams.resource} must match a resource from * the agent's protectedResources in root state. Analogous to RFC 6750 * bearer token delivery. */ - authenticate(params: IAuthenticateParams): Promise; + authenticate(params: AuthenticateParams): Promise; /** List all available sessions from the Copilot CLI. */ listSessions(): Promise; @@ -554,16 +554,16 @@ export interface IAgentService { createSession(config?: IAgentCreateSessionConfig): Promise; /** Resolve the dynamic configuration schema for creating a session. */ - resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise; + resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise; /** Return dynamic completions for a session configuration property. */ - sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise; + sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise; /** Dispose a session in the agent host, freeing SDK resources. */ disposeSession(session: URI): Promise; /** Create a new terminal on the agent host. */ - createTerminal(params: ICreateTerminalParams): Promise; + createTerminal(params: CreateTerminalParams): Promise; /** Dispose a terminal and kill its process if still running. */ disposeTerminal(terminal: URI): Promise; @@ -588,7 +588,7 @@ export interface IAgentService { * Clients use this alongside {@link subscribe} to keep their local * state in sync. */ - readonly onDidAction: Event; + readonly onDidAction: Event; /** * Fires when the server broadcasts an ephemeral notification @@ -601,40 +601,40 @@ 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: ISessionAction | ITerminalAction, clientId: string, clientSeq: number): void; + dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void; /** * List the contents of a directory on the agent host's filesystem. * Used by the client to drive a remote folder picker before session creation. */ - resourceList(uri: URI): Promise; + resourceList(uri: URI): Promise; /** * Read stored content by URI from the agent host (e.g. file edit snapshots, * or reading files from the remote filesystem). */ - resourceRead(uri: URI): Promise; + resourceRead(uri: URI): Promise; /** * Write content to a file on the agent host's filesystem. * Used for undo/redo operations on file edits. */ - resourceWrite(params: IResourceWriteParams): Promise; + resourceWrite(params: ResourceWriteParams): Promise; /** * Copy a resource from one URI to another on the agent host's filesystem. */ - resourceCopy(params: IResourceCopyParams): Promise; + resourceCopy(params: ResourceCopyParams): Promise; /** * Delete a resource at a URI on the agent host's filesystem. */ - resourceDelete(params: IResourceDeleteParams): Promise; + resourceDelete(params: ResourceDeleteParams): Promise; /** * Move (rename) a resource from one URI to another on the agent host's filesystem. */ - resourceMove(params: IResourceMoveParams): Promise; + resourceMove(params: ResourceMoveParams): Promise; } /** @@ -649,36 +649,36 @@ export interface IAgentConnection { readonly clientId: string; // ---- State subscriptions ------------------------------------------------ - readonly rootState: IAgentSubscription; + readonly rootState: IAgentSubscription; getSubscription(kind: T, resource: URI): IReference>; getSubscriptionUnmanaged(kind: T, resource: URI): IAgentSubscription | undefined; // ---- Action dispatch ---------------------------------------------------- - dispatch(action: ISessionAction | ITerminalAction): void; + dispatch(action: SessionAction | TerminalAction): void; // ---- Events (connection-level) ------------------------------------------ readonly onDidNotification: Event; - readonly onDidAction: Event; + readonly onDidAction: Event; // ---- Session lifecycle -------------------------------------------------- - authenticate(params: IAuthenticateParams): Promise; + authenticate(params: AuthenticateParams): Promise; listSessions(): Promise; createSession(config?: IAgentCreateSessionConfig): Promise; - resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise; - sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise; + resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise; + sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise; disposeSession(session: URI): Promise; // ---- Terminal lifecycle ------------------------------------------------- - createTerminal(params: ICreateTerminalParams): Promise; + createTerminal(params: CreateTerminalParams): Promise; disposeTerminal(terminal: URI): Promise; // ---- Filesystem operations ---------------------------------------------- - resourceList(uri: URI): Promise; - resourceRead(uri: URI): Promise; - resourceWrite(params: IResourceWriteParams): Promise; - resourceCopy(params: IResourceCopyParams): Promise; - resourceDelete(params: IResourceDeleteParams): Promise; - resourceMove(params: IResourceMoveParams): Promise; + resourceList(uri: URI): Promise; + resourceRead(uri: URI): Promise; + resourceWrite(params: ResourceWriteParams): Promise; + resourceCopy(params: ResourceCopyParams): Promise; + resourceDelete(params: ResourceDeleteParams): Promise; + resourceMove(params: ResourceMoveParams): Promise; } export const IAgentHostService = createDecorator('agentHostService'); diff --git a/src/vs/platform/agentHost/common/remoteAgentHostService.ts b/src/vs/platform/agentHost/common/remoteAgentHostService.ts index 73cf6beb37cfd..230a2d5edb59a 100644 --- a/src/vs/platform/agentHost/common/remoteAgentHostService.ts +++ b/src/vs/platform/agentHost/common/remoteAgentHostService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../base/common/event.js'; +import { IDisposable } from '../../../base/common/lifecycle.js'; import { connectionTokenQueryName } from '../../../base/common/network.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { IAgentConnection } from './agentService.js'; @@ -169,8 +170,18 @@ export interface IRemoteAgentHostService { * Register a pre-connected agent connection. * Used by the SSH and tunnel services to inject relay-backed connections * without going through the WebSocket connect flow. + * + * The optional `transportDisposable` represents the underlying transport + * (e.g. an SSH tunnel relay or tunnel-relay session) and is owned by this + * service for the lifetime of the entry. It will be disposed when: + * - the entry is removed via {@link removeRemoteAgentHost} + * - the entry is reconciled away (config-driven removal) + * - this service itself is disposed + * Callers should put any teardown that needs to happen on entry removal + * (e.g. closing the shared-process tunnel, dropping renderer-side handles) + * into this disposable, so a single removal path tears down the whole stack. */ - addSSHConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection): Promise; + addManagedConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection, transportDisposable?: IDisposable): Promise; /** * Look up the {@link IRemoteAgentHostEntry} for a given address. @@ -200,7 +211,7 @@ export class NullRemoteAgentHostService implements IRemoteAgentHostService { } async removeRemoteAgentHost(_address: string): Promise { } reconnect(_address: string): void { } - async addSSHConnection(): Promise { + async addManagedConnection(): Promise { throw new Error('Remote agent host connections are not supported in this environment.'); } getEntryByAddress(): IRemoteAgentHostEntry | undefined { return undefined; } diff --git a/src/vs/platform/agentHost/common/state/agentSubscription.ts b/src/vs/platform/agentHost/common/state/agentSubscription.ts index 2f17e6086d605..c36b92cd96320 100644 --- a/src/vs/platform/agentHost/common/state/agentSubscription.ts +++ b/src/vs/platform/agentHost/common/state/agentSubscription.ts @@ -7,11 +7,11 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, IReference } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { URI } from '../../../../base/common/uri.js'; -import { IActionEnvelope, ISessionAction, IStateAction, isSessionAction } from './sessionActions.js'; +import { ActionEnvelope, SessionAction, StateAction, isSessionAction } from './sessionActions.js'; import { rootReducer, sessionReducer } from './sessionReducers.js'; import { terminalReducer } from './protocol/reducers.js'; -import type { IRootAction, ISessionAction as IProtocolSessionAction, ITerminalAction } from './protocol/action-origin.generated.js'; -import type { IRootState, ISessionState, ITerminalState } from './protocol/state.js'; +import type { RootAction, SessionAction as IProtocolSessionAction, TerminalAction } from './protocol/action-origin.generated.js'; +import type { RootState, SessionState, TerminalState } from './protocol/state.js'; import type { IStateSnapshot } from './sessionProtocol.js'; import { StateComponents } from './sessionState.js'; @@ -45,10 +45,10 @@ export interface IAgentSubscription { readonly onDidChange: Event; /** Fires before a server-originated action is applied to this subscription's state. */ - readonly onWillApplyAction: Event; + readonly onWillApplyAction: Event; /** Fires after a server-originated action is applied to this subscription's state. */ - readonly onDidApplyAction: Event; + readonly onDidApplyAction: Event; } // --- Base Implementation ----------------------------------------------------- @@ -64,16 +64,16 @@ abstract class BaseAgentSubscription extends Disposable implements IAgentSubs protected _confirmedState: T | undefined; private _error: Error | undefined; - private _bufferedEnvelopes: IActionEnvelope[] | undefined; + private _bufferedEnvelopes: ActionEnvelope[] | undefined; protected readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; - protected readonly _onWillApplyAction = this._register(new Emitter()); - readonly onWillApplyAction: Event = this._onWillApplyAction.event; + protected readonly _onWillApplyAction = this._register(new Emitter()); + readonly onWillApplyAction: Event = this._onWillApplyAction.event; - protected readonly _onDidApplyAction = this._register(new Emitter()); - readonly onDidApplyAction: Event = this._onDidApplyAction.event; + protected readonly _onDidApplyAction = this._register(new Emitter()); + readonly onDidApplyAction: Event = this._onDidApplyAction.event; protected readonly _clientId: string; protected readonly _log: (msg: string) => void; @@ -116,7 +116,7 @@ abstract class BaseAgentSubscription extends Disposable implements IAgentSubs * Process an incoming action envelope. The subscription determines * whether the action is relevant via {@link _isRelevantAction}. */ - receiveEnvelope(envelope: IActionEnvelope): void { + receiveEnvelope(envelope: ActionEnvelope): void { if (!this._isRelevantAction(envelope.action)) { return; } @@ -140,10 +140,10 @@ abstract class BaseAgentSubscription extends Disposable implements IAgentSubs } /** Apply the reducer to confirmed state. Subclasses must implement. */ - protected abstract _applyReducer(state: T, action: IStateAction): T; + protected abstract _applyReducer(state: T, action: StateAction): T; /** Whether the given action targets this subscription. */ - protected abstract _isRelevantAction(action: IStateAction): boolean; + protected abstract _isRelevantAction(action: StateAction): boolean; /** Return optimistic state if write-ahead is active, otherwise `undefined`. */ protected _getOptimisticState(): T | undefined { @@ -170,7 +170,7 @@ abstract class BaseAgentSubscription extends Disposable implements IAgentSubs * Default reconciliation: apply to confirmed, fire change event. * Session subscriptions override this for write-ahead. */ - protected _reconcile(envelope: IActionEnvelope, _isOwnAction: boolean): void { + protected _reconcile(envelope: ActionEnvelope, _isOwnAction: boolean): void { this._confirmedState = this._applyReducer(this._confirmedState!, envelope.action); this._onDidChange.fire(this.value as T); } @@ -182,13 +182,13 @@ abstract class BaseAgentSubscription extends Disposable implements IAgentSubs * Subscription to the root state at `agenthost:/root`. * Server-only mutations — no write-ahead. */ -export class RootStateSubscription extends BaseAgentSubscription { +export class RootStateSubscription extends BaseAgentSubscription { - protected override _applyReducer(state: IRootState, action: IStateAction): IRootState { - return rootReducer(state, action as IRootAction, this._log); + protected override _applyReducer(state: RootState, action: StateAction): RootState { + return rootReducer(state, action as RootAction, this._log); } - protected override _isRelevantAction(action: IStateAction): boolean { + protected override _isRelevantAction(action: StateAction): boolean { return action.type.startsWith('root/'); } } @@ -197,17 +197,17 @@ export class RootStateSubscription extends BaseAgentSubscription { interface IPendingAction { readonly clientSeq: number; - readonly action: ISessionAction; + readonly action: SessionAction; } /** * Subscription to a session at `copilot:/`. * Supports write-ahead reconciliation for client-dispatchable actions. */ -export class SessionStateSubscription extends BaseAgentSubscription { +export class SessionStateSubscription extends BaseAgentSubscription { private readonly _pendingActions: IPendingAction[] = []; - private _optimisticState: ISessionState | undefined; + private _optimisticState: SessionState | undefined; private readonly _sessionUri: string; private readonly _seqAllocator: () => number; @@ -226,7 +226,7 @@ export class SessionStateSubscription extends BaseAgentSubscription p.clientSeq === envelope.origin!.clientSeq); if (idx !== -1) { @@ -276,7 +276,7 @@ export class SessionStateSubscription extends BaseAgentSubscription { +export class TerminalStateSubscription extends BaseAgentSubscription { private readonly _terminalUri: string; @@ -327,11 +327,11 @@ export class TerminalStateSubscription extends BaseAgentSubscription { + get rootState(): IAgentSubscription { return this._rootState; } @@ -384,7 +384,7 @@ export class AgentSubscriptionManager extends Disposable { * Initialize the root state from a snapshot received during the * connection handshake. */ - handleRootSnapshot(state: IRootState, fromSeq: number): void { + handleRootSnapshot(state: RootState, fromSeq: number): void { this._rootState.handleSnapshot(state, fromSeq); } @@ -440,7 +440,7 @@ export class AgentSubscriptionManager extends Disposable { /** * Route an incoming action envelope to all active subscriptions. */ - receiveEnvelope(envelope: IActionEnvelope): void { + receiveEnvelope(envelope: ActionEnvelope): void { // Root state gets all root actions this._rootState.receiveEnvelope(envelope); // Other subscriptions get filtered actions @@ -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: ISessionAction | ITerminalAction): number { + dispatchOptimistic(action: 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/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index f9c684bfe333f..40883ce5db8c2 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -0947b17 +bfc35fb diff --git a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts index 63f51511b8b51..20dbbf0f49883 100644 --- a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts +++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts @@ -9,144 +9,146 @@ // Generated from types/actions.ts — do not edit // Run `npm run generate` to regenerate. -import { ActionType, type IStateAction, type IRootAgentsChangedAction, type IRootActiveSessionsChangedAction, type IRootTerminalsChangedAction, type ISessionReadyAction, type ISessionCreationFailedAction, type ISessionTurnStartedAction, type ISessionDeltaAction, type ISessionResponsePartAction, type ISessionToolCallStartAction, type ISessionToolCallDeltaAction, type ISessionToolCallReadyAction, type ISessionToolCallConfirmedAction, type ISessionToolCallCompleteAction, type ISessionToolCallResultConfirmedAction, type ISessionToolCallContentChangedAction, type ISessionTurnCompleteAction, type ISessionTurnCancelledAction, type ISessionErrorAction, type ISessionTitleChangedAction, type ISessionUsageAction, type ISessionReasoningAction, type ISessionModelChangedAction, type ISessionServerToolsChangedAction, type ISessionActiveClientChangedAction, type ISessionActiveClientToolsChangedAction, type ISessionPendingMessageSetAction, type ISessionPendingMessageRemovedAction, type ISessionQueuedMessagesReorderedAction, type ISessionInputRequestedAction, type ISessionInputAnswerChangedAction, type ISessionInputCompletedAction, type ISessionCustomizationsChangedAction, type ISessionCustomizationToggledAction, type ISessionTruncatedAction, type ISessionIsReadChangedAction, type ISessionIsDoneChangedAction, type ISessionDiffsChangedAction, type ISessionConfigChangedAction, type ITerminalDataAction, type ITerminalInputAction, type ITerminalResizedAction, type ITerminalClaimedAction, type ITerminalTitleChangedAction, type ITerminalCwdChangedAction, type ITerminalExitedAction, type ITerminalClearedAction, type ITerminalCommandDetectionAvailableAction, type ITerminalCommandExecutedAction, type ITerminalCommandFinishedAction } from './actions.js'; +import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionTurnStartedAction, type SessionDeltaAction, type SessionResponsePartAction, type SessionToolCallStartAction, type SessionToolCallDeltaAction, type SessionToolCallReadyAction, type SessionToolCallConfirmedAction, type SessionToolCallCompleteAction, type SessionToolCallResultConfirmedAction, type SessionToolCallContentChangedAction, type SessionTurnCompleteAction, type SessionTurnCancelledAction, type SessionErrorAction, type SessionTitleChangedAction, type SessionUsageAction, type SessionReasoningAction, type SessionModelChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientChangedAction, type SessionActiveClientToolsChangedAction, type SessionPendingMessageSetAction, type SessionPendingMessageRemovedAction, type SessionQueuedMessagesReorderedAction, type SessionInputRequestedAction, type SessionInputAnswerChangedAction, type SessionInputCompletedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionTruncatedAction, type SessionIsReadChangedAction, type SessionIsDoneChangedAction, type SessionDiffsChangedAction, type SessionConfigChangedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction } from './actions.js'; // ─── Root vs Session vs Terminal Action Unions ─────────────────────────────── /** Union of all root-scoped actions. */ -export type IRootAction = - | IRootAgentsChangedAction - | IRootActiveSessionsChangedAction - | IRootTerminalsChangedAction +export type RootAction = + | RootAgentsChangedAction + | RootActiveSessionsChangedAction + | RootTerminalsChangedAction + | RootConfigChangedAction ; /** Union of all session-scoped actions. */ -export type ISessionAction = - | ISessionReadyAction - | ISessionCreationFailedAction - | ISessionTurnStartedAction - | ISessionDeltaAction - | ISessionResponsePartAction - | ISessionToolCallStartAction - | ISessionToolCallDeltaAction - | ISessionToolCallReadyAction - | ISessionToolCallConfirmedAction - | ISessionToolCallCompleteAction - | ISessionToolCallResultConfirmedAction - | ISessionToolCallContentChangedAction - | ISessionTurnCompleteAction - | ISessionTurnCancelledAction - | ISessionErrorAction - | ISessionTitleChangedAction - | ISessionUsageAction - | ISessionReasoningAction - | ISessionModelChangedAction - | ISessionServerToolsChangedAction - | ISessionActiveClientChangedAction - | ISessionActiveClientToolsChangedAction - | ISessionPendingMessageSetAction - | ISessionPendingMessageRemovedAction - | ISessionQueuedMessagesReorderedAction - | ISessionInputRequestedAction - | ISessionInputAnswerChangedAction - | ISessionInputCompletedAction - | ISessionCustomizationsChangedAction - | ISessionCustomizationToggledAction - | ISessionTruncatedAction - | ISessionIsReadChangedAction - | ISessionIsDoneChangedAction - | ISessionDiffsChangedAction - | ISessionConfigChangedAction +export type SessionAction = + | SessionReadyAction + | SessionCreationFailedAction + | SessionTurnStartedAction + | SessionDeltaAction + | SessionResponsePartAction + | SessionToolCallStartAction + | SessionToolCallDeltaAction + | SessionToolCallReadyAction + | SessionToolCallConfirmedAction + | SessionToolCallCompleteAction + | SessionToolCallResultConfirmedAction + | SessionToolCallContentChangedAction + | SessionTurnCompleteAction + | SessionTurnCancelledAction + | SessionErrorAction + | SessionTitleChangedAction + | SessionUsageAction + | SessionReasoningAction + | SessionModelChangedAction + | SessionServerToolsChangedAction + | SessionActiveClientChangedAction + | SessionActiveClientToolsChangedAction + | SessionPendingMessageSetAction + | SessionPendingMessageRemovedAction + | SessionQueuedMessagesReorderedAction + | SessionInputRequestedAction + | SessionInputAnswerChangedAction + | SessionInputCompletedAction + | SessionCustomizationsChangedAction + | SessionCustomizationToggledAction + | SessionTruncatedAction + | SessionIsReadChangedAction + | SessionIsDoneChangedAction + | SessionDiffsChangedAction + | SessionConfigChangedAction ; /** Union of session actions that clients may dispatch. */ -export type IClientSessionAction = - | ISessionTurnStartedAction - | ISessionToolCallConfirmedAction - | ISessionToolCallCompleteAction - | ISessionToolCallResultConfirmedAction - | ISessionToolCallContentChangedAction - | ISessionTurnCancelledAction - | ISessionTitleChangedAction - | ISessionModelChangedAction - | ISessionActiveClientChangedAction - | ISessionActiveClientToolsChangedAction - | ISessionPendingMessageSetAction - | ISessionPendingMessageRemovedAction - | ISessionQueuedMessagesReorderedAction - | ISessionInputAnswerChangedAction - | ISessionInputCompletedAction - | ISessionCustomizationToggledAction - | ISessionTruncatedAction - | ISessionIsReadChangedAction - | ISessionIsDoneChangedAction - | ISessionConfigChangedAction +export type ClientSessionAction = + | SessionTurnStartedAction + | SessionToolCallConfirmedAction + | SessionToolCallCompleteAction + | SessionToolCallResultConfirmedAction + | SessionToolCallContentChangedAction + | SessionTurnCancelledAction + | SessionTitleChangedAction + | SessionModelChangedAction + | SessionActiveClientChangedAction + | SessionActiveClientToolsChangedAction + | SessionPendingMessageSetAction + | SessionPendingMessageRemovedAction + | SessionQueuedMessagesReorderedAction + | SessionInputAnswerChangedAction + | SessionInputCompletedAction + | SessionCustomizationToggledAction + | SessionTruncatedAction + | SessionIsReadChangedAction + | SessionIsDoneChangedAction + | SessionConfigChangedAction ; /** Union of session actions that only the server may produce. */ -export type IServerSessionAction = - | ISessionReadyAction - | ISessionCreationFailedAction - | ISessionDeltaAction - | ISessionResponsePartAction - | ISessionToolCallStartAction - | ISessionToolCallDeltaAction - | ISessionToolCallReadyAction - | ISessionTurnCompleteAction - | ISessionErrorAction - | ISessionUsageAction - | ISessionReasoningAction - | ISessionServerToolsChangedAction - | ISessionInputRequestedAction - | ISessionCustomizationsChangedAction - | ISessionDiffsChangedAction +export type ServerSessionAction = + | SessionReadyAction + | SessionCreationFailedAction + | SessionDeltaAction + | SessionResponsePartAction + | SessionToolCallStartAction + | SessionToolCallDeltaAction + | SessionToolCallReadyAction + | SessionTurnCompleteAction + | SessionErrorAction + | SessionUsageAction + | SessionReasoningAction + | SessionServerToolsChangedAction + | SessionInputRequestedAction + | SessionCustomizationsChangedAction + | SessionDiffsChangedAction ; /** Union of all terminal-scoped actions. */ -export type ITerminalAction = - | ITerminalDataAction - | ITerminalInputAction - | ITerminalResizedAction - | ITerminalClaimedAction - | ITerminalTitleChangedAction - | ITerminalCwdChangedAction - | ITerminalExitedAction - | ITerminalClearedAction - | ITerminalCommandDetectionAvailableAction - | ITerminalCommandExecutedAction - | ITerminalCommandFinishedAction +export type TerminalAction = + | TerminalDataAction + | TerminalInputAction + | TerminalResizedAction + | TerminalClaimedAction + | TerminalTitleChangedAction + | TerminalCwdChangedAction + | TerminalExitedAction + | TerminalClearedAction + | TerminalCommandDetectionAvailableAction + | TerminalCommandExecutedAction + | TerminalCommandFinishedAction ; /** Union of terminal actions that clients may dispatch. */ -export type IClientTerminalAction = - | ITerminalInputAction - | ITerminalResizedAction - | ITerminalClaimedAction - | ITerminalTitleChangedAction - | ITerminalClearedAction +export type ClientTerminalAction = + | TerminalInputAction + | TerminalResizedAction + | TerminalClaimedAction + | TerminalTitleChangedAction + | TerminalClearedAction ; /** Union of terminal actions that only the server may produce. */ -export type IServerTerminalAction = - | ITerminalDataAction - | ITerminalCwdChangedAction - | ITerminalExitedAction - | ITerminalCommandDetectionAvailableAction - | ITerminalCommandExecutedAction - | ITerminalCommandFinishedAction +export type ServerTerminalAction = + | TerminalDataAction + | TerminalCwdChangedAction + | TerminalExitedAction + | TerminalCommandDetectionAvailableAction + | TerminalCommandExecutedAction + | TerminalCommandFinishedAction ; // ─── Client-Dispatchable Map ───────────────────────────────────────────────── /** * Exhaustive map indicating which action types may be dispatched by clients. - * Adding a new action to IStateAction without adding it here is a compile error. + * Adding a new action to StateAction without adding it here is a compile error. */ -export const IS_CLIENT_DISPATCHABLE: { readonly [K in IStateAction['type']]: boolean } = { +export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: boolean } = { [ActionType.RootAgentsChanged]: false, [ActionType.RootActiveSessionsChanged]: false, [ActionType.RootTerminalsChanged]: false, + [ActionType.RootConfigChanged]: true, [ActionType.SessionReady]: false, [ActionType.SessionCreationFailed]: false, [ActionType.SessionTurnStarted]: true, diff --git a/src/vs/platform/agentHost/common/state/protocol/actions.ts b/src/vs/platform/agentHost/common/state/protocol/actions.ts index 52beb0e9811cd..4eef97a3a6793 100644 --- a/src/vs/platform/agentHost/common/state/protocol/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/actions.ts @@ -6,7 +6,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type URI, type StringOrMarkdown, type IAgentInfo, type IErrorInfo, type IModelSelection, type IUserMessage, type IResponsePart, type IToolCallResult, type IToolResultContent, type IToolDefinition, type ISessionActiveClient, type IUsageInfo, type ISessionCustomization, type IFileEdit, type ISessionInputAnswer, type ISessionInputRequest, type ITerminalInfo, type ITerminalClaim, type SessionInputResponseKind } from './state.js'; +import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type URI, type StringOrMarkdown, type AgentInfo, type ErrorInfo, type ModelSelection, type UserMessage, type ResponsePart, type ToolCallResult, type ToolResultContent, type ToolDefinition, type SessionActiveClient, type UsageInfo, type SessionCustomization, type FileEdit, type SessionInputAnswer, type SessionInputRequest, type TerminalInfo, type TerminalClaim, type SessionInputResponseKind, type ConfirmationOption } from './state.js'; // ─── Action Type Enum ──────────────────────────────────────────────────────── @@ -55,6 +55,7 @@ export const enum ActionType { SessionDiffsChanged = 'session/diffsChanged', SessionConfigChanged = 'session/configChanged', RootTerminalsChanged = 'root/terminalsChanged', + RootConfigChanged = 'root/configChanged', TerminalData = 'terminal/data', TerminalInput = 'terminal/input', TerminalResized = 'terminal/resized', @@ -73,7 +74,7 @@ export const enum ActionType { /** * Identifies the client that originally dispatched an action. */ -export interface IActionOrigin { +export interface ActionOrigin { clientId: string; clientSeq: number; } @@ -81,10 +82,10 @@ export interface IActionOrigin { /** * Every action is wrapped in an `ActionEnvelope`. */ -export interface IActionEnvelope { - readonly action: IStateAction; +export interface ActionEnvelope { + readonly action: StateAction; readonly serverSeq: number; - readonly origin: IActionOrigin | undefined; + readonly origin: ActionOrigin | undefined; readonly rejectionReason?: string; } @@ -96,7 +97,7 @@ export interface IActionEnvelope { * * @category Session Actions */ -interface IToolCallActionBase { +interface ToolCallActionBase { /** Session URI */ session: URI; /** Turn identifier */ @@ -120,10 +121,10 @@ interface IToolCallActionBase { * @category Root Actions * @version 1 */ -export interface IRootAgentsChangedAction { +export interface RootAgentsChangedAction { type: ActionType.RootAgentsChanged; /** Updated agent list */ - agents: IAgentInfo[]; + agents: AgentInfo[]; } /** @@ -132,7 +133,7 @@ export interface IRootAgentsChangedAction { * @category Root Actions * @version 1 */ -export interface IRootActiveSessionsChangedAction { +export interface RootActiveSessionsChangedAction { type: ActionType.RootActiveSessionsChanged; /** Current count of active sessions */ activeSessions: number; @@ -147,10 +148,28 @@ export interface IRootActiveSessionsChangedAction { * @category Root Actions * @version 1 */ -export interface IRootTerminalsChangedAction { +export interface RootTerminalsChangedAction { type: ActionType.RootTerminalsChanged; /** Updated terminal list (full replacement) */ - terminals: ITerminalInfo[]; + terminals: TerminalInfo[]; +} + +/** + * Fired when agent-host configuration values change. + * + * By default, the reducer merges the new values into `state.config.values`. + * Set `replace` to `true` to replace all values instead of merging. + * + * @category Root Actions + * @version 1 + * @clientDispatchable + */ +export interface RootConfigChangedAction { + type: ActionType.RootConfigChanged; + /** Updated config values */ + config: Record; + /** When `true`, replaces all config values instead of merging */ + replace?: boolean; } // ─── Session Actions ───────────────────────────────────────────────────────── @@ -161,7 +180,7 @@ export interface IRootTerminalsChangedAction { * @category Session Actions * @version 1 */ -export interface ISessionReadyAction { +export interface SessionReadyAction { type: ActionType.SessionReady; /** Session URI */ session: URI; @@ -173,12 +192,12 @@ export interface ISessionReadyAction { * @category Session Actions * @version 1 */ -export interface ISessionCreationFailedAction { +export interface SessionCreationFailedAction { type: ActionType.SessionCreationFailed; /** Session URI */ session: URI; /** Error details */ - error: IErrorInfo; + error: ErrorInfo; } /** @@ -188,14 +207,14 @@ export interface ISessionCreationFailedAction { * @version 1 * @clientDispatchable */ -export interface ISessionTurnStartedAction { +export interface SessionTurnStartedAction { type: ActionType.SessionTurnStarted; /** Session URI */ session: URI; /** Turn identifier */ turnId: string; /** User's message */ - userMessage: IUserMessage; + userMessage: UserMessage; /** If this turn was auto-started from a queued message, the ID of that message */ queuedMessageId?: string; } @@ -209,7 +228,7 @@ export interface ISessionTurnStartedAction { * @category Session Actions * @version 1 */ -export interface ISessionDeltaAction { +export interface SessionDeltaAction { type: ActionType.SessionDelta; /** Session URI */ session: URI; @@ -227,14 +246,14 @@ export interface ISessionDeltaAction { * @category Session Actions * @version 1 */ -export interface ISessionResponsePartAction { +export interface SessionResponsePartAction { type: ActionType.SessionResponsePart; /** Session URI */ session: URI; /** Turn identifier */ turnId: string; /** Response part (markdown or content ref) */ - part: IResponsePart; + part: ResponsePart; } /** @@ -247,7 +266,7 @@ export interface ISessionResponsePartAction { * @category Session Actions * @version 1 */ -export interface ISessionToolCallStartAction extends IToolCallActionBase { +export interface SessionToolCallStartAction extends ToolCallActionBase { type: ActionType.SessionToolCallStart; /** Internal tool name (for debugging/logging) */ toolName: string; @@ -266,7 +285,7 @@ export interface ISessionToolCallStartAction extends IToolCallActionBase { * @category Session Actions * @version 1 */ -export interface ISessionToolCallDeltaAction extends IToolCallActionBase { +export interface SessionToolCallDeltaAction extends ToolCallActionBase { type: ActionType.SessionToolCallDelta; /** Partial parameter content to append */ content: string; @@ -292,7 +311,7 @@ export interface ISessionToolCallDeltaAction extends IToolCallActionBase { * @category Session Actions * @version 1 */ -export interface ISessionToolCallReadyAction extends IToolCallActionBase { +export interface SessionToolCallReadyAction extends ToolCallActionBase { type: ActionType.SessionToolCallReady; /** Message describing what the tool will do or what confirmation is needed */ invocationMessage: StringOrMarkdown; @@ -301,11 +320,18 @@ export interface ISessionToolCallReadyAction extends IToolCallActionBase { /** Short title for the confirmation prompt (e.g. `"Run in terminal"`, `"Write file"`) */ confirmationTitle?: StringOrMarkdown; /** File edits that this tool call will perform, for preview before confirmation */ - edits?: { items: IFileEdit[] }; + edits?: { items: FileEdit[] }; /** Whether the agent host allows the client to edit the tool's input parameters before confirming */ editable?: boolean; /** If set, the tool was auto-confirmed and transitions directly to `running` */ confirmed?: ToolCallConfirmationReason; + /** + * Options the server offers for this confirmation. When present, the client + * SHOULD render these instead of a plain approve/deny UI. Each option + * belongs to a {@link ConfirmationOptionGroup} so the client can still + * categorise the choices. + */ + options?: ConfirmationOption[]; } /** @@ -315,7 +341,7 @@ export interface ISessionToolCallReadyAction extends IToolCallActionBase { * @version 1 * @clientDispatchable */ -export interface ISessionToolCallApprovedAction extends IToolCallActionBase { +export interface SessionToolCallApprovedAction extends ToolCallActionBase { type: ActionType.SessionToolCallConfirmed; /** The tool call was approved */ approved: true; @@ -323,6 +349,8 @@ export interface ISessionToolCallApprovedAction extends IToolCallActionBase { confirmed: ToolCallConfirmationReason; /** Edited tool input parameters, if the client modified them before confirming */ editedToolInput?: string; + /** ID of the selected confirmation option, if the server provided options */ + selectedOptionId?: string; } /** @@ -335,16 +363,18 @@ export interface ISessionToolCallApprovedAction extends IToolCallActionBase { * @version 1 * @clientDispatchable */ -export interface ISessionToolCallDeniedAction extends IToolCallActionBase { +export interface SessionToolCallDeniedAction extends ToolCallActionBase { type: ActionType.SessionToolCallConfirmed; /** The tool call was denied */ approved: false; /** Why the tool was cancelled */ reason: ToolCallCancellationReason.Denied | ToolCallCancellationReason.Skipped; /** What the user suggested doing instead */ - userSuggestion?: IUserMessage; + userSuggestion?: UserMessage; /** Optional explanation for the denial */ reasonMessage?: StringOrMarkdown; + /** ID of the selected confirmation option, if the server provided options */ + selectedOptionId?: string; } /** @@ -354,9 +384,9 @@ export interface ISessionToolCallDeniedAction extends IToolCallActionBase { * @version 1 * @clientDispatchable */ -export type ISessionToolCallConfirmedAction = - | ISessionToolCallApprovedAction - | ISessionToolCallDeniedAction; +export type SessionToolCallConfirmedAction = + | SessionToolCallApprovedAction + | SessionToolCallDeniedAction; /** * Tool execution finished. Transitions to `completed` or `pending-result-confirmation` @@ -374,10 +404,10 @@ export type ISessionToolCallConfirmedAction = * @version 1 * @clientDispatchable */ -export interface ISessionToolCallCompleteAction extends IToolCallActionBase { +export interface SessionToolCallCompleteAction extends ToolCallActionBase { type: ActionType.SessionToolCallComplete; /** Execution result */ - result: IToolCallResult; + result: ToolCallResult; /** If true, the result requires client approval before finalizing */ requiresResultConfirmation?: boolean; } @@ -391,7 +421,7 @@ export interface ISessionToolCallCompleteAction extends IToolCallActionBase { * @version 1 * @clientDispatchable */ -export interface ISessionToolCallResultConfirmedAction extends IToolCallActionBase { +export interface SessionToolCallResultConfirmedAction extends ToolCallActionBase { type: ActionType.SessionToolCallResultConfirmed; /** Whether the result was approved */ approved: boolean; @@ -413,10 +443,10 @@ export interface ISessionToolCallResultConfirmedAction extends IToolCallActionBa * @version 1 * @clientDispatchable */ -export interface ISessionToolCallContentChangedAction extends IToolCallActionBase { +export interface SessionToolCallContentChangedAction extends ToolCallActionBase { type: ActionType.SessionToolCallContentChanged; /** The current partial content for the running tool call */ - content: IToolResultContent[]; + content: ToolResultContent[]; } /** @@ -425,7 +455,7 @@ export interface ISessionToolCallContentChangedAction extends IToolCallActionBas * @category Session Actions * @version 1 */ -export interface ISessionTurnCompleteAction { +export interface SessionTurnCompleteAction { type: ActionType.SessionTurnComplete; /** Session URI */ session: URI; @@ -440,7 +470,7 @@ export interface ISessionTurnCompleteAction { * @version 1 * @clientDispatchable */ -export interface ISessionTurnCancelledAction { +export interface SessionTurnCancelledAction { type: ActionType.SessionTurnCancelled; /** Session URI */ session: URI; @@ -454,14 +484,14 @@ export interface ISessionTurnCancelledAction { * @category Session Actions * @version 1 */ -export interface ISessionErrorAction { +export interface SessionErrorAction { type: ActionType.SessionError; /** Session URI */ session: URI; /** Turn identifier */ turnId: string; /** Error details */ - error: IErrorInfo; + error: ErrorInfo; } /** @@ -472,7 +502,7 @@ export interface ISessionErrorAction { * @clientDispatchable * @version 1 */ -export interface ISessionTitleChangedAction { +export interface SessionTitleChangedAction { type: ActionType.SessionTitleChanged; /** Session URI */ session: URI; @@ -486,14 +516,14 @@ export interface ISessionTitleChangedAction { * @category Session Actions * @version 1 */ -export interface ISessionUsageAction { +export interface SessionUsageAction { type: ActionType.SessionUsage; /** Session URI */ session: URI; /** Turn identifier */ turnId: string; /** Token usage data */ - usage: IUsageInfo; + usage: UsageInfo; } /** @@ -505,7 +535,7 @@ export interface ISessionUsageAction { * @category Session Actions * @version 1 */ -export interface ISessionReasoningAction { +export interface SessionReasoningAction { type: ActionType.SessionReasoning; /** Session URI */ session: URI; @@ -524,12 +554,12 @@ export interface ISessionReasoningAction { * @version 1 * @clientDispatchable */ -export interface ISessionModelChangedAction { +export interface SessionModelChangedAction { type: ActionType.SessionModelChanged; /** Session URI */ session: URI; /** New model selection */ - model: IModelSelection; + model: ModelSelection; } /** @@ -542,7 +572,7 @@ export interface ISessionModelChangedAction { * @version 1 * @clientDispatchable */ -export interface ISessionIsReadChangedAction { +export interface SessionIsReadChangedAction { type: ActionType.SessionIsReadChanged; /** Session URI */ session: URI; @@ -560,7 +590,7 @@ export interface ISessionIsReadChangedAction { * @version 1 * @clientDispatchable */ -export interface ISessionIsDoneChangedAction { +export interface SessionIsDoneChangedAction { type: ActionType.SessionIsDoneChanged; /** Session URI */ session: URI; @@ -577,12 +607,12 @@ export interface ISessionIsDoneChangedAction { * @category Session Actions * @version 1 */ -export interface ISessionDiffsChangedAction { +export interface SessionDiffsChangedAction { type: ActionType.SessionDiffsChanged; /** Session URI */ session: URI; /** Updated file diffs for the session */ - diffs: IFileEdit[]; + diffs: FileEdit[]; } /** @@ -593,18 +623,18 @@ export interface ISessionDiffsChangedAction { * @category Session Actions * @version 1 */ -export interface ISessionServerToolsChangedAction { +export interface SessionServerToolsChangedAction { type: ActionType.SessionServerToolsChanged; /** Session URI */ session: URI; /** Updated server tools list (full replacement) */ - tools: IToolDefinition[]; + tools: ToolDefinition[]; } /** * The active client for this session has changed. * - * A client dispatches this action with its own `ISessionActiveClient` to claim + * A client dispatches this action with its own `SessionActiveClient` to claim * the active role, or with `null` to release it. The server SHOULD reject if * another client is already active. The server SHOULD automatically dispatch * this action with `activeClient: null` when the active client disconnects. @@ -613,12 +643,12 @@ export interface ISessionServerToolsChangedAction { * @version 1 * @clientDispatchable */ -export interface ISessionActiveClientChangedAction { +export interface SessionActiveClientChangedAction { type: ActionType.SessionActiveClientChanged; /** Session URI */ session: URI; /** The new active client, or `null` to unset */ - activeClient: ISessionActiveClient | null; + activeClient: SessionActiveClient | null; } /** @@ -632,12 +662,12 @@ export interface ISessionActiveClientChangedAction { * @version 1 * @clientDispatchable */ -export interface ISessionActiveClientToolsChangedAction { +export interface SessionActiveClientToolsChangedAction { type: ActionType.SessionActiveClientToolsChanged; /** Session URI */ session: URI; /** Updated client tools list (full replacement) */ - tools: IToolDefinition[]; + tools: ToolDefinition[]; } // ─── Customization Actions ─────────────────────────────────────────────────── @@ -651,12 +681,12 @@ export interface ISessionActiveClientToolsChangedAction { * @category Session Actions * @version 1 */ -export interface ISessionCustomizationsChangedAction { +export interface SessionCustomizationsChangedAction { type: ActionType.SessionCustomizationsChanged; /** Session URI */ session: URI; /** Updated customization list (full replacement) */ - customizations: ISessionCustomization[]; + customizations: SessionCustomization[]; } /** @@ -669,7 +699,7 @@ export interface ISessionCustomizationsChangedAction { * @version 1 * @clientDispatchable */ -export interface ISessionCustomizationToggledAction { +export interface SessionCustomizationToggledAction { type: ActionType.SessionCustomizationToggled; /** Session URI */ session: URI; @@ -692,12 +722,14 @@ export interface ISessionCustomizationToggledAction { * @version 1 * @clientDispatchable */ -export interface ISessionConfigChangedAction { +export interface SessionConfigChangedAction { type: ActionType.SessionConfigChanged; /** Session URI */ session: URI; - /** Updated config values (merged into existing config) */ - config: Record; + /** Updated config values */ + config: Record; + /** When `true`, replaces all config values instead of merging */ + replace?: boolean; } // ─── Truncation ────────────────────────────────────────────────────────────── @@ -717,7 +749,7 @@ export interface ISessionConfigChangedAction { * @version 1 * @clientDispatchable */ -export interface ISessionTruncatedAction { +export interface SessionTruncatedAction { type: ActionType.SessionTruncated; /** Session URI */ session: URI; @@ -740,7 +772,7 @@ export interface ISessionTruncatedAction { * @version 1 * @clientDispatchable */ -export interface ISessionPendingMessageSetAction { +export interface SessionPendingMessageSetAction { type: ActionType.SessionPendingMessageSet; /** Session URI */ session: URI; @@ -749,7 +781,7 @@ export interface ISessionPendingMessageSetAction { /** Unique identifier for this pending message */ id: string; /** The message content */ - userMessage: IUserMessage; + userMessage: UserMessage; } /** @@ -763,7 +795,7 @@ export interface ISessionPendingMessageSetAction { * @version 1 * @clientDispatchable */ -export interface ISessionPendingMessageRemovedAction { +export interface SessionPendingMessageRemovedAction { type: ActionType.SessionPendingMessageRemoved; /** Session URI */ session: URI; @@ -786,7 +818,7 @@ export interface ISessionPendingMessageRemovedAction { * @version 1 * @clientDispatchable */ -export interface ISessionQueuedMessagesReorderedAction { +export interface SessionQueuedMessagesReorderedAction { type: ActionType.SessionQueuedMessagesReordered; /** Session URI */ session: URI; @@ -806,12 +838,12 @@ export interface ISessionQueuedMessagesReorderedAction { * @category Session Actions * @version 1 */ -export interface ISessionInputRequestedAction { +export interface SessionInputRequestedAction { type: ActionType.SessionInputRequested; /** Session URI */ session: URI; /** Input request to create or replace */ - request: ISessionInputRequest; + request: SessionInputRequest; } /** @@ -823,7 +855,7 @@ export interface ISessionInputRequestedAction { * @version 1 * @clientDispatchable */ -export interface ISessionInputAnswerChangedAction { +export interface SessionInputAnswerChangedAction { type: ActionType.SessionInputAnswerChanged; /** Session URI */ session: URI; @@ -832,7 +864,7 @@ export interface ISessionInputAnswerChangedAction { /** Question identifier within the input request */ questionId: string; /** Updated answer, or `undefined` to clear an answer draft */ - answer?: ISessionInputAnswer; + answer?: SessionInputAnswer; } /** @@ -845,7 +877,7 @@ export interface ISessionInputAnswerChangedAction { * @version 1 * @clientDispatchable */ -export interface ISessionInputCompletedAction { +export interface SessionInputCompletedAction { type: ActionType.SessionInputCompleted; /** Session URI */ session: URI; @@ -854,7 +886,7 @@ export interface ISessionInputCompletedAction { /** Completion outcome */ response: SessionInputResponseKind; /** Optional final answer replacement, keyed by question ID */ - answers?: Record; + answers?: Record; } // ─── Terminal Actions ──────────────────────────────────────────────────────── @@ -874,7 +906,7 @@ export interface ISessionInputCompletedAction { * @category Terminal Actions * @version 1 */ -export interface ITerminalDataAction { +export interface TerminalDataAction { type: ActionType.TerminalData; /** Terminal URI */ terminal: URI; @@ -895,7 +927,7 @@ export interface ITerminalDataAction { * @version 1 * @clientDispatchable */ -export interface ITerminalInputAction { +export interface TerminalInputAction { type: ActionType.TerminalInput; /** Terminal URI */ terminal: URI; @@ -913,7 +945,7 @@ export interface ITerminalInputAction { * @version 1 * @clientDispatchable */ -export interface ITerminalResizedAction { +export interface TerminalResizedAction { type: ActionType.TerminalResized; /** Terminal URI */ terminal: URI; @@ -933,12 +965,12 @@ export interface ITerminalResizedAction { * @version 1 * @clientDispatchable */ -export interface ITerminalClaimedAction { +export interface TerminalClaimedAction { type: ActionType.TerminalClaimed; /** Terminal URI */ terminal: URI; /** The new claim */ - claim: ITerminalClaim; + claim: TerminalClaim; } /** @@ -951,7 +983,7 @@ export interface ITerminalClaimedAction { * @version 1 * @clientDispatchable */ -export interface ITerminalTitleChangedAction { +export interface TerminalTitleChangedAction { type: ActionType.TerminalTitleChanged; /** Terminal URI */ terminal: URI; @@ -965,7 +997,7 @@ export interface ITerminalTitleChangedAction { * @category Terminal Actions * @version 1 */ -export interface ITerminalCwdChangedAction { +export interface TerminalCwdChangedAction { type: ActionType.TerminalCwdChanged; /** Terminal URI */ terminal: URI; @@ -979,7 +1011,7 @@ export interface ITerminalCwdChangedAction { * @category Terminal Actions * @version 1 */ -export interface ITerminalExitedAction { +export interface TerminalExitedAction { type: ActionType.TerminalExited; /** Terminal URI */ terminal: URI; @@ -994,7 +1026,7 @@ export interface ITerminalExitedAction { * @version 1 * @clientDispatchable */ -export interface ITerminalClearedAction { +export interface TerminalClearedAction { type: ActionType.TerminalCleared; /** Terminal URI */ terminal: URI; @@ -1011,7 +1043,7 @@ export interface ITerminalClearedAction { * @category Terminal Actions * @version 1 */ -export interface ITerminalCommandDetectionAvailableAction { +export interface TerminalCommandDetectionAvailableAction { type: ActionType.TerminalCommandDetectionAvailable; /** Terminal URI */ terminal: URI; @@ -1025,7 +1057,7 @@ export interface ITerminalCommandDetectionAvailableAction { * @category Terminal Actions * @version 1 */ -export interface ITerminalCommandExecutedAction { +export interface TerminalCommandExecutedAction { type: ActionType.TerminalCommandExecuted; /** Terminal URI */ terminal: URI; @@ -1053,7 +1085,7 @@ export interface ITerminalCommandExecutedAction { * @category Terminal Actions * @version 1 */ -export interface ITerminalCommandFinishedAction { +export interface TerminalCommandFinishedAction { type: ActionType.TerminalCommandFinished; /** Terminal URI */ terminal: URI; @@ -1073,53 +1105,54 @@ export interface ITerminalCommandFinishedAction { /** * Discriminated union of all state actions. */ -export type IStateAction = - | IRootAgentsChangedAction - | IRootActiveSessionsChangedAction - | IRootTerminalsChangedAction - | ISessionReadyAction - | ISessionCreationFailedAction - | ISessionTurnStartedAction - | ISessionDeltaAction - | ISessionResponsePartAction - | ISessionToolCallStartAction - | ISessionToolCallDeltaAction - | ISessionToolCallReadyAction - | ISessionToolCallConfirmedAction - | ISessionToolCallCompleteAction - | ISessionToolCallResultConfirmedAction - | ISessionToolCallContentChangedAction - | ISessionTurnCompleteAction - | ISessionTurnCancelledAction - | ISessionErrorAction - | ISessionTitleChangedAction - | ISessionUsageAction - | ISessionReasoningAction - | ISessionModelChangedAction - | ISessionServerToolsChangedAction - | ISessionActiveClientChangedAction - | ISessionActiveClientToolsChangedAction - | ISessionPendingMessageSetAction - | ISessionPendingMessageRemovedAction - | ISessionQueuedMessagesReorderedAction - | ISessionInputRequestedAction - | ISessionInputAnswerChangedAction - | ISessionInputCompletedAction - | ISessionCustomizationsChangedAction - | ISessionCustomizationToggledAction - | ISessionTruncatedAction - | ISessionIsReadChangedAction - | ISessionIsDoneChangedAction - | ISessionDiffsChangedAction - | ISessionConfigChangedAction - | ITerminalDataAction - | ITerminalInputAction - | ITerminalResizedAction - | ITerminalClaimedAction - | ITerminalTitleChangedAction - | ITerminalCwdChangedAction - | ITerminalExitedAction - | ITerminalClearedAction - | ITerminalCommandDetectionAvailableAction - | ITerminalCommandExecutedAction - | ITerminalCommandFinishedAction; +export type StateAction = + | RootAgentsChangedAction + | RootActiveSessionsChangedAction + | RootTerminalsChangedAction + | RootConfigChangedAction + | SessionReadyAction + | SessionCreationFailedAction + | SessionTurnStartedAction + | SessionDeltaAction + | SessionResponsePartAction + | SessionToolCallStartAction + | SessionToolCallDeltaAction + | SessionToolCallReadyAction + | SessionToolCallConfirmedAction + | SessionToolCallCompleteAction + | SessionToolCallResultConfirmedAction + | SessionToolCallContentChangedAction + | SessionTurnCompleteAction + | SessionTurnCancelledAction + | SessionErrorAction + | SessionTitleChangedAction + | SessionUsageAction + | SessionReasoningAction + | SessionModelChangedAction + | SessionServerToolsChangedAction + | SessionActiveClientChangedAction + | SessionActiveClientToolsChangedAction + | SessionPendingMessageSetAction + | SessionPendingMessageRemovedAction + | SessionQueuedMessagesReorderedAction + | SessionInputRequestedAction + | SessionInputAnswerChangedAction + | SessionInputCompletedAction + | SessionCustomizationsChangedAction + | SessionCustomizationToggledAction + | SessionTruncatedAction + | SessionIsReadChangedAction + | SessionIsDoneChangedAction + | SessionDiffsChangedAction + | SessionConfigChangedAction + | TerminalDataAction + | TerminalInputAction + | TerminalResizedAction + | TerminalClaimedAction + | TerminalTitleChangedAction + | TerminalCwdChangedAction + | TerminalExitedAction + | TerminalClearedAction + | TerminalCommandDetectionAvailableAction + | TerminalCommandExecutedAction + | TerminalCommandFinishedAction; diff --git a/src/vs/platform/agentHost/common/state/protocol/commands.ts b/src/vs/platform/agentHost/common/state/protocol/commands.ts index a68103156dd8b..2dd449a4e0d1f 100644 --- a/src/vs/platform/agentHost/common/state/protocol/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/commands.ts @@ -6,10 +6,10 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import type { URI, ISnapshot, ISessionConfigSchema, ISessionSummary, IModelSelection, ITurn, ITerminalClaim, ISessionActiveClient } from './state.js'; -import type { IActionEnvelope, IStateAction } from './actions.js'; +import type { URI, Snapshot, SessionConfigSchema, SessionSummary, ModelSelection, Turn, TerminalClaim, SessionActiveClient } from './state.js'; +import type { ActionEnvelope, StateAction } from './actions.js'; -export type { IConfigPropertySchema, IConfigSchema, ISessionConfigPropertySchema, ISessionConfigSchema } from './state.js'; +export type { ConfigPropertySchema, ConfigSchema, SessionConfigPropertySchema, SessionConfigSchema } from './state.js'; // ─── initialize ────────────────────────────────────────────────────────────── @@ -24,13 +24,19 @@ export type { IConfigPropertySchema, IConfigSchema, ISessionConfigPropertySchema * @version 1 * @see {@link /specification/lifecycle | Lifecycle} for the full handshake flow. */ -export interface IInitializeParams { +export interface InitializeParams { /** Protocol version the client speaks */ protocolVersion: number; /** Unique client identifier */ clientId: string; /** URIs to subscribe to during handshake */ initialSubscriptions?: URI[]; + /** + * IETF BCP 47 language tag indicating the client's preferred locale + * (e.g. `"en-US"`, `"ja"`). The server SHOULD use this to localise + * user-facing strings such as confirmation option labels. + */ + locale?: string; } /** @@ -39,13 +45,13 @@ export interface IInitializeParams { * If the server does not support the client's protocol version, it MUST return * error code `-32005` (`UnsupportedProtocolVersion`). */ -export interface IInitializeResult { +export interface InitializeResult { /** Protocol version the server speaks */ protocolVersion: number; /** Current server sequence number */ serverSeq: number; /** Snapshots for each `initialSubscriptions` URI */ - snapshots: ISnapshot[]; + snapshots: Snapshot[]; /** Suggested default directory for remote filesystem browsing */ defaultDirectory?: URI; } @@ -73,7 +79,7 @@ export const enum ReconnectResultType { * @version 1 * @see {@link /specification/lifecycle | Lifecycle} for details. */ -export interface IReconnectParams { +export interface ReconnectParams { /** Client identifier from the original connection */ clientId: string; /** Last `serverSeq` the client received */ @@ -87,25 +93,25 @@ export interface IReconnectParams { * * The server MUST include all replayed data in the response. */ -export interface IReconnectReplayResult { +export interface ReconnectReplayResult { /** Discriminant */ type: ReconnectResultType.Replay; /** Missed action envelopes since `lastSeenServerSeq` */ - actions: IActionEnvelope[]; + actions: ActionEnvelope[]; } /** * Reconnect result when the gap exceeds the replay buffer. */ -export interface IReconnectSnapshotResult { +export interface ReconnectSnapshotResult { /** Discriminant */ type: ReconnectResultType.Snapshot; /** Fresh snapshots for each subscription */ - snapshots: ISnapshot[]; + snapshots: Snapshot[]; } /** Result of the `reconnect` command. */ -export type IReconnectResult = IReconnectReplayResult | IReconnectSnapshotResult; +export type ReconnectResult = ReconnectReplayResult | ReconnectSnapshotResult; // ─── subscribe ─────────────────────────────────────────────────────────────── @@ -119,7 +125,7 @@ export type IReconnectResult = IReconnectReplayResult | IReconnectSnapshotResult * @version 1 * @see {@link /specification/subscriptions | Subscriptions} */ -export interface ISubscribeParams { +export interface SubscribeParams { /** URI to subscribe to */ resource: URI; } @@ -127,9 +133,9 @@ export interface ISubscribeParams { /** * Result of the `subscribe` command. */ -export interface ISubscribeResult { +export interface SubscribeResult { /** Snapshot of the subscribed resource */ - snapshot: ISnapshot; + snapshot: Snapshot; } // ─── createSession ─────────────────────────────────────────────────────────── @@ -172,32 +178,32 @@ export interface ISubscribeResult { * content from the source session up to and including the response of the * specified turn. */ -export interface ISessionForkSource { +export interface SessionForkSource { /** URI of the existing session to fork from */ session: URI; /** Turn ID in the source session; content up to and including this turn's response is copied */ turnId: string; } -export interface ICreateSessionParams { +export interface CreateSessionParams { /** Session URI (client-chosen, e.g. `copilot:/`) */ session: URI; /** Agent provider ID */ provider?: string; /** Model selection (ID and optional model-specific configuration) */ - model?: IModelSelection; + model?: ModelSelection; /** Working directory for the session */ workingDirectory?: URI; /** * Fork from an existing session. The new session is populated with content * from the source session up to and including the specified turn's response. */ - fork?: ISessionForkSource; + fork?: SessionForkSource; /** * Agent-specific configuration values collected via `resolveSessionConfig`. * Keys and values correspond to the schema returned by the server. */ - config?: Record; + config?: Record; /** * Eagerly claim the active client role for the new session. * @@ -206,7 +212,7 @@ export interface ICreateSessionParams { * action immediately after creation. The `clientId` MUST match the * `clientId` the creating client supplied in `initialize`. */ - activeClient?: ISessionActiveClient; + activeClient?: SessionActiveClient; } // ─── disposeSession ────────────────────────────────────────────────────────── @@ -222,7 +228,7 @@ export interface ICreateSessionParams { * @messageType Request * @version 1 */ -export interface IDisposeSessionParams { +export interface DisposeSessionParams { /** Session URI to dispose */ session: URI; } @@ -242,11 +248,11 @@ export interface IDisposeSessionParams { * @messageType Request * @version 1 */ -export interface ICreateTerminalParams { +export interface CreateTerminalParams { /** Terminal URI (client-chosen) */ terminal: URI; /** Initial owner of the terminal */ - claim: ITerminalClaim; + claim: TerminalClaim; /** Human-readable terminal name */ name?: string; /** Initial working directory URI */ @@ -271,7 +277,7 @@ export interface ICreateTerminalParams { * @messageType Request * @version 1 */ -export interface IDisposeTerminalParams { +export interface DisposeTerminalParams { /** Terminal URI to dispose */ terminal: URI; } @@ -291,15 +297,15 @@ export interface IDisposeTerminalParams { * @messageType Request * @version 1 */ -export interface IListSessionsParams { +export interface ListSessionsParams { /** Optional filter criteria */ filter?: object; } /** Result of the `listSessions` command. */ -export interface IListSessionsResult { +export interface ListSessionsResult { /** The list of session summaries. */ - items: ISessionSummary[]; + items: SessionSummary[]; } // ─── resourceRead ──────────────────────────────────────────────────────── @@ -344,7 +350,7 @@ export const enum ContentEncoding { * }} * ``` */ -export interface IResourceReadParams { +export interface ResourceReadParams { /** Content URI from a `ContentRef` */ uri: string; /** Preferred encoding for the returned data (default: server-chosen) */ @@ -358,7 +364,7 @@ export interface IResourceReadParams { * server cannot provide the requested encoding, it MUST fall back to either * `base64` or `utf-8`. */ -export interface IResourceReadResult { +export interface ResourceReadResult { /** Content encoded as a string */ data: string; /** How `data` is encoded */ @@ -397,7 +403,7 @@ export interface IResourceReadResult { * { "jsonrpc": "2.0", "id": 11, "result": {} } * ``` */ -export interface IResourceWriteParams { +export interface ResourceWriteParams { /** Target file URI on the server filesystem */ uri: URI; /** Content encoded as a string */ @@ -418,7 +424,7 @@ export interface IResourceWriteParams { * * An empty object on success. */ -export interface IResourceWriteResult { +export interface ResourceWriteResult { } // ─── resourceList ──────────────────────────────────────────────────────── @@ -441,7 +447,7 @@ export interface IResourceWriteResult { * @throws `NotFound` (`-32008`) if the directory does not exist. * @throws `PermissionDenied` (`-32009`) if the client is not permitted to browse the directory. */ -export interface IResourceListParams { +export interface ResourceListParams { /** Directory URI on the server filesystem */ uri: URI; } @@ -449,7 +455,7 @@ export interface IResourceListParams { /** * Directory entry returned by `resourceList`. */ -export interface IDirectoryEntry { +export interface DirectoryEntry { /** Base name of the entry */ name: string; /** Whether the entry is a file or directory */ @@ -459,9 +465,9 @@ export interface IDirectoryEntry { /** * Result of the `resourceList` command. */ -export interface IResourceListResult { +export interface ResourceListResult { /** Entries directly contained in the requested directory */ - entries: IDirectoryEntry[]; + entries: DirectoryEntry[]; } // ─── fetchTurns ────────────────────────────────────────────────────────────── @@ -492,7 +498,7 @@ export interface IResourceListResult { * "params": { "session": "copilot:/", "before": "t1", "limit": 20 } } * ``` */ -export interface IFetchTurnsParams { +export interface FetchTurnsParams { /** Session URI */ session: URI; /** Turn ID to fetch before (exclusive). Omit to fetch from the most recent turn. */ @@ -504,9 +510,9 @@ export interface IFetchTurnsParams { /** * Result of the `fetchTurns` command. */ -export interface IFetchTurnsResult { +export interface FetchTurnsResult { /** The requested turns, ordered oldest-first */ - turns: ITurn[]; + turns: Turn[]; /** Whether more turns exist before the returned range */ hasMore: boolean; } @@ -523,7 +529,7 @@ export interface IFetchTurnsResult { * @version 1 * @see {@link /specification/subscriptions | Subscriptions} */ -export interface IUnsubscribeParams { +export interface UnsubscribeParams { /** URI to unsubscribe from */ resource: URI; } @@ -541,11 +547,11 @@ export interface IUnsubscribeParams { * @version 1 * @see {@link /guide/actions | Actions} for the full list of client-dispatchable actions. */ -export interface IDispatchActionParams { +export interface DispatchActionParams { /** Client sequence number */ clientSeq: number; /** The action to dispatch */ - action: IStateAction; + action: StateAction; } // ─── resourceCopy ──────────────────────────────────────────────────────────── @@ -565,7 +571,7 @@ export interface IDispatchActionParams { * @throws `PermissionDenied` (`-32009`) if the client is not permitted to read the source or write to the destination. * @throws `AlreadyExists` (`-32010`) if `failIfExists` is set and the destination already exists. */ -export interface IResourceCopyParams { +export interface ResourceCopyParams { /** Source URI to copy from */ source: URI; /** Destination URI to copy to */ @@ -582,7 +588,7 @@ export interface IResourceCopyParams { * * An empty object on success. */ -export interface IResourceCopyResult { +export interface ResourceCopyResult { } // ─── resourceDelete ────────────────────────────────────────────────────────── @@ -598,7 +604,7 @@ export interface IResourceCopyResult { * @throws `NotFound` (`-32008`) if the resource does not exist. * @throws `PermissionDenied` (`-32009`) if the client is not permitted to delete the resource. */ -export interface IResourceDeleteParams { +export interface ResourceDeleteParams { /** URI of the resource to delete */ uri: URI; /** @@ -613,7 +619,7 @@ export interface IResourceDeleteParams { * * An empty object on success. */ -export interface IResourceDeleteResult { +export interface ResourceDeleteResult { } // ─── resourceMove ──────────────────────────────────────────────────────────── @@ -633,7 +639,7 @@ export interface IResourceDeleteResult { * @throws `PermissionDenied` (`-32009`) if the client is not permitted to move the resource. * @throws `AlreadyExists` (`-32010`) if `failIfExists` is set and the destination already exists. */ -export interface IResourceMoveParams { +export interface ResourceMoveParams { /** Source URI to move from */ source: URI; /** Destination URI to move to */ @@ -650,15 +656,15 @@ export interface IResourceMoveParams { * * An empty object on success. */ -export interface IResourceMoveResult { +export interface ResourceMoveResult { } // ─── authenticate ──────────────────────────────────────────────────────────── /** * Pushes a Bearer token for a protected resource. The `resource` field MUST - * match an `IProtectedResourceMetadata.resource` value declared by an agent - * in `IAgentInfo.protectedResources`. + * match a `ProtectedResourceMetadata.resource` value declared by an agent + * in `AgentInfo.protectedResources`. * * Tokens are delivered using [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750) * (Bearer Token Usage) semantics. The client obtains the token from the @@ -684,10 +690,10 @@ export interface IResourceMoveResult { * { "jsonrpc": "2.0", "id": 3, "error": { "code": -32007, "message": "Invalid token" } } * ``` */ -export interface IAuthenticateParams { +export interface AuthenticateParams { /** * The protected resource identifier. MUST match a `resource` value from - * `IProtectedResourceMetadata` declared in `IAgentInfo.protectedResources`. + * `ProtectedResourceMetadata` declared in `AgentInfo.protectedResources`. */ resource: string; /** Bearer token obtained from the resource's authorization server */ @@ -701,7 +707,7 @@ export interface IAuthenticateParams { * unrecognized, the server MUST return a JSON-RPC error (e.g. `AuthRequired` * `-32007` or `InvalidParams` `-32602`). */ -export interface IAuthenticateResult { +export interface AuthenticateResult { } // ─── resolveSessionConfig ──────────────────────────────────────────────────── @@ -762,23 +768,23 @@ export interface IAuthenticateResult { * }} * ``` */ -export interface IResolveSessionConfigParams { +export interface ResolveSessionConfigParams { /** Agent provider ID */ provider?: string; /** Working directory for the session */ workingDirectory?: URI; /** Current user-filled configuration values */ - config?: Record; + config?: Record; } /** * Result of the `resolveSessionConfig` command. */ -export interface IResolveSessionConfigResult { +export interface ResolveSessionConfigResult { /** JSON Schema describing available configuration properties given the current context */ - schema: ISessionConfigSchema; + schema: SessionConfigSchema; /** Current configuration values (echoed back with server-resolved defaults applied) */ - values: Record; + values: Record; } // ─── sessionConfigCompletions ──────────────────────────────────────────────── @@ -788,7 +794,7 @@ export interface IResolveSessionConfigResult { * * @category Commands */ -export interface ISessionConfigValueItem { +export interface SessionConfigValueItem { /** The value to store in config */ value: string; /** Human-readable display label */ @@ -826,13 +832,13 @@ export interface ISessionConfigValueItem { * }} * ``` */ -export interface ISessionConfigCompletionsParams { +export interface SessionConfigCompletionsParams { /** Agent provider ID */ provider?: string; /** Working directory for the session */ workingDirectory?: URI; /** Current user-filled configuration values (provides context for the query) */ - config?: Record; + config?: Record; /** Property id from the schema to query values for */ property: string; /** Search filter text (empty or omitted returns default/recent values) */ @@ -842,7 +848,7 @@ export interface ISessionConfigCompletionsParams { /** * Result of the `sessionConfigCompletions` command. */ -export interface ISessionConfigCompletionsResult { +export interface SessionConfigCompletionsResult { /** Matching value items */ - items: ISessionConfigValueItem[]; + items: SessionConfigValueItem[]; } diff --git a/src/vs/platform/agentHost/common/state/protocol/errors.ts b/src/vs/platform/agentHost/common/state/protocol/errors.ts index bcf0e7947de3b..f288da18756bc 100644 --- a/src/vs/platform/agentHost/common/state/protocol/errors.ts +++ b/src/vs/platform/agentHost/common/state/protocol/errors.ts @@ -50,7 +50,7 @@ export const AhpErrorCodes = { /** * A command failed because the client has not authenticated for a required * protected resource. The `data` field of the JSON-RPC error SHOULD contain - * an `IProtectedResourceMetadata[]` array describing the resources that + * a `ProtectedResourceMetadata[]` array describing the resources that * require authentication. * * @see {@link /specification/authentication | Authentication} diff --git a/src/vs/platform/agentHost/common/state/protocol/messages.ts b/src/vs/platform/agentHost/common/state/protocol/messages.ts index 68f2335a3153a..d7d0f2e4cf145 100644 --- a/src/vs/platform/agentHost/common/state/protocol/messages.ts +++ b/src/vs/platform/agentHost/common/state/protocol/messages.ts @@ -6,15 +6,15 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import type { IInitializeParams, IInitializeResult, IReconnectParams, IReconnectResult, ISubscribeParams, ISubscribeResult, ICreateSessionParams, IDisposeSessionParams, ICreateTerminalParams, IDisposeTerminalParams, IListSessionsParams, IListSessionsResult, IResourceReadParams, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IResourceListParams, IResourceListResult, IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceMoveParams, IResourceMoveResult, IFetchTurnsParams, IFetchTurnsResult, IUnsubscribeParams, IDispatchActionParams, IAuthenticateParams, IAuthenticateResult, IResolveSessionConfigParams, IResolveSessionConfigResult, ISessionConfigCompletionsParams, ISessionConfigCompletionsResult } from './commands.js'; +import type { InitializeParams, InitializeResult, ReconnectParams, ReconnectResult, SubscribeParams, SubscribeResult, CreateSessionParams, DisposeSessionParams, CreateTerminalParams, DisposeTerminalParams, ListSessionsParams, ListSessionsResult, ResourceReadParams, ResourceReadResult, ResourceWriteParams, ResourceWriteResult, ResourceListParams, ResourceListResult, ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceMoveParams, ResourceMoveResult, FetchTurnsParams, FetchTurnsResult, UnsubscribeParams, DispatchActionParams, AuthenticateParams, AuthenticateResult, ResolveSessionConfigParams, ResolveSessionConfigResult, SessionConfigCompletionsParams, SessionConfigCompletionsResult } from './commands.js'; -import type { IActionEnvelope } from './actions.js'; -import type { IProtocolNotification } from './notifications.js'; +import type { ActionEnvelope } from './actions.js'; +import type { ProtocolNotification } from './notifications.js'; // ─── JSON-RPC Base Types ───────────────────────────────────────────────────── /** A JSON-RPC request: has both `method` and `id`. */ -export interface IJsonRpcRequest { +export interface JsonRpcRequest { readonly jsonrpc: '2.0'; readonly id: number; readonly method: string; @@ -22,14 +22,14 @@ export interface IJsonRpcRequest { } /** A JSON-RPC success response. */ -export interface IJsonRpcSuccessResponse { +export interface JsonRpcSuccessResponse { readonly jsonrpc: '2.0'; readonly id: number; readonly result: unknown; } /** A JSON-RPC error response. */ -export interface IJsonRpcErrorResponse { +export interface JsonRpcErrorResponse { readonly jsonrpc: '2.0'; readonly id: number; readonly error: { @@ -40,10 +40,10 @@ export interface IJsonRpcErrorResponse { } /** A JSON-RPC response (success or error). */ -export type IJsonRpcResponse = IJsonRpcSuccessResponse | IJsonRpcErrorResponse; +export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse; /** A JSON-RPC notification: has `method` but no `id`. */ -export interface IJsonRpcNotification { +export interface JsonRpcNotification { readonly jsonrpc: '2.0'; readonly method: string; readonly params?: unknown; @@ -56,32 +56,32 @@ export interface IJsonRpcNotification { * * @category Commands */ -export interface ICommandMap { - 'initialize': { params: IInitializeParams; result: IInitializeResult }; - 'reconnect': { params: IReconnectParams; result: IReconnectResult }; - 'subscribe': { params: ISubscribeParams; result: ISubscribeResult }; - 'createSession': { params: ICreateSessionParams; result: null }; - 'disposeSession': { params: IDisposeSessionParams; result: null }; - 'createTerminal': { params: ICreateTerminalParams; result: null }; - 'disposeTerminal': { params: IDisposeTerminalParams; result: null }; - 'listSessions': { params: IListSessionsParams; result: IListSessionsResult }; - 'resourceRead': { params: IResourceReadParams; result: IResourceReadResult }; - 'resourceWrite': { params: IResourceWriteParams; result: IResourceWriteResult }; - 'resourceList': { params: IResourceListParams; result: IResourceListResult }; - 'resourceCopy': { params: IResourceCopyParams; result: IResourceCopyResult }; - 'resourceDelete': { params: IResourceDeleteParams; result: IResourceDeleteResult }; - 'resourceMove': { params: IResourceMoveParams; result: IResourceMoveResult }; - 'fetchTurns': { params: IFetchTurnsParams; result: IFetchTurnsResult }; - 'authenticate': { params: IAuthenticateParams; result: IAuthenticateResult }; - 'resolveSessionConfig': { params: IResolveSessionConfigParams; result: IResolveSessionConfigResult }; - 'sessionConfigCompletions': { params: ISessionConfigCompletionsParams; result: ISessionConfigCompletionsResult }; +export interface CommandMap { + 'initialize': { params: InitializeParams; result: InitializeResult }; + 'reconnect': { params: ReconnectParams; result: ReconnectResult }; + 'subscribe': { params: SubscribeParams; result: SubscribeResult }; + 'createSession': { params: CreateSessionParams; result: null }; + 'disposeSession': { params: DisposeSessionParams; result: null }; + 'createTerminal': { params: CreateTerminalParams; result: null }; + 'disposeTerminal': { params: DisposeTerminalParams; result: null }; + 'listSessions': { params: ListSessionsParams; result: ListSessionsResult }; + 'resourceRead': { params: ResourceReadParams; result: ResourceReadResult }; + 'resourceWrite': { params: ResourceWriteParams; result: ResourceWriteResult }; + 'resourceList': { params: ResourceListParams; result: ResourceListResult }; + 'resourceCopy': { params: ResourceCopyParams; result: ResourceCopyResult }; + 'resourceDelete': { params: ResourceDeleteParams; result: ResourceDeleteResult }; + 'resourceMove': { params: ResourceMoveParams; result: ResourceMoveResult }; + 'fetchTurns': { params: FetchTurnsParams; result: FetchTurnsResult }; + 'authenticate': { params: AuthenticateParams; result: AuthenticateResult }; + 'resolveSessionConfig': { params: ResolveSessionConfigParams; result: ResolveSessionConfigResult }; + 'sessionConfigCompletions': { params: SessionConfigCompletionsParams; result: SessionConfigCompletionsResult }; } // ─── Notification Maps ─────────────────────────────────────────────────────── /** Params for the server → client `notification` method. */ -export interface INotificationMethodParams { - notification: IProtocolNotification; +export interface NotificationMethodParams { + notification: ProtocolNotification; } /** @@ -89,9 +89,9 @@ export interface INotificationMethodParams { * * @category Notifications */ -export interface IClientNotificationMap { - 'unsubscribe': { params: IUnsubscribeParams }; - 'dispatchAction': { params: IDispatchActionParams }; +export interface ClientNotificationMap { + 'unsubscribe': { params: UnsubscribeParams }; + 'dispatchAction': { params: DispatchActionParams }; } /** @@ -99,13 +99,13 @@ export interface IClientNotificationMap { * * @category Notifications */ -export interface IServerNotificationMap { - 'action': { params: IActionEnvelope }; - 'notification': { params: INotificationMethodParams }; +export interface ServerNotificationMap { + 'action': { params: ActionEnvelope }; + 'notification': { params: NotificationMethodParams }; } /** Combined notification map for all directions. */ -export type INotificationMap = IClientNotificationMap & IServerNotificationMap; +export type NotificationMap = ClientNotificationMap & ServerNotificationMap; // ─── Typed Requests ────────────────────────────────────────────────────────── @@ -115,19 +115,19 @@ export type INotificationMap = IClientNotificationMap & IServerNotificationMap; * When used as a union (default generic), narrowing on `method` gives typed `params`: * * ```ts - * function handle(req: IAhpRequest) { + * function handle(req: AhpRequest) { * if (req.method === 'fetchTurns') { * req.params.session; // typed as URI * } * } * ``` */ -export type IAhpRequest = +export type AhpRequest = M extends unknown ? { readonly jsonrpc: '2.0'; readonly id: number; readonly method: M; - readonly params: ICommandMap[M]['params']; + readonly params: CommandMap[M]['params']; } : never; // ─── Typed Responses ───────────────────────────────────────────────────────── @@ -139,21 +139,21 @@ export type IAhpRequest = * generic parameter when you know the method from the associated request: * * ```ts - * const result: IAhpSuccessResponse<'fetchTurns'> = ...; - * result.result.turns; // typed as ITurn[] + * const result: AhpSuccessResponse<'fetchTurns'> = ...; + * result.result.turns; // typed as Turn[] * ``` */ -export type IAhpSuccessResponse = +export type AhpSuccessResponse = M extends unknown ? { readonly jsonrpc: '2.0'; readonly id: number; - readonly result: ICommandMap[M]['result']; + readonly result: CommandMap[M]['result']; } : never; /** Typed JSON-RPC response (success with known result type, or error). */ -export type IAhpResponse = - | IAhpSuccessResponse - | IJsonRpcErrorResponse; +export type AhpResponse = + | AhpSuccessResponse + | JsonRpcErrorResponse; // ─── Typed Notifications ───────────────────────────────────────────────────── @@ -163,34 +163,34 @@ export type IAhpResponse = * When used as a union (default generic), narrowing on `method` gives typed `params`: * * ```ts - * function handle(notif: IAhpNotification) { + * function handle(notif: AhpNotification) { * if (notif.method === 'action') { * notif.params.serverSeq; // typed as number * } * } * ``` */ -export type IAhpNotification = +export type AhpNotification = M extends unknown ? { readonly jsonrpc: '2.0'; readonly method: M; - readonly params: INotificationMap[M]['params']; + readonly params: NotificationMap[M]['params']; } : never; /** A client → server notification. */ -export type IAhpClientNotification = +export type AhpClientNotification = M extends unknown ? { readonly jsonrpc: '2.0'; readonly method: M; - readonly params: IClientNotificationMap[M]['params']; + readonly params: ClientNotificationMap[M]['params']; } : never; /** A server → client notification. */ -export type IAhpServerNotification = +export type AhpServerNotification = M extends unknown ? { readonly jsonrpc: '2.0'; readonly method: M; - readonly params: IServerNotificationMap[M]['params']; + readonly params: ServerNotificationMap[M]['params']; } : never; // ─── Protocol Message Union ────────────────────────────────────────────────── @@ -199,16 +199,16 @@ export type IAhpServerNotification; + changes: Partial; } /** @@ -182,7 +182,7 @@ export interface ISessionSummaryChangedNotification { * } * ``` */ -export interface IAuthRequiredNotification { +export interface AuthRequiredNotification { type: NotificationType.AuthRequired; /** The protected resource identifier that requires authentication */ resource: string; @@ -193,8 +193,8 @@ export interface IAuthRequiredNotification { /** * Discriminated union of all protocol notifications. */ -export type IProtocolNotification = - | ISessionAddedNotification - | ISessionRemovedNotification - | ISessionSummaryChangedNotification - | IAuthRequiredNotification; +export type ProtocolNotification = + | SessionAddedNotification + | SessionRemovedNotification + | SessionSummaryChangedNotification + | AuthRequiredNotification; diff --git a/src/vs/platform/agentHost/common/state/protocol/reducers.ts b/src/vs/platform/agentHost/common/state/protocol/reducers.ts index f308599642b4e..5ac4557a75d8d 100644 --- a/src/vs/platform/agentHost/common/state/protocol/reducers.ts +++ b/src/vs/platform/agentHost/common/state/protocol/reducers.ts @@ -7,8 +7,8 @@ // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts import { ActionType } from './actions.js'; -import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, ResponsePartKind, PendingMessageKind, type IRootState, type ISessionInputRequest, type ISessionState, type ITerminalState, type ITerminalContentPart, type IToolCallState, type IResponsePart, type IToolCallResponsePart, type ITurn, type IPendingMessage } from './state.js'; -import { IS_CLIENT_DISPATCHABLE, type IRootAction, type ISessionAction, type IClientSessionAction, type ITerminalAction, type IClientTerminalAction } from './action-origin.generated.js'; +import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, ResponsePartKind, PendingMessageKind, type RootState, type SessionInputRequest, type SessionState, type TerminalState, type TerminalContentPart, type ToolCallState, type ResponsePart, type ToolCallResponsePart, type Turn, type PendingMessage, type ConfirmationOption } from './state.js'; +import { IS_CLIENT_DISPATCHABLE, type RootAction, type SessionAction, type ClientSessionAction, type TerminalAction, type ClientTerminalAction } from './action-origin.generated.js'; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -26,7 +26,7 @@ export function softAssertNever(value: never, log?: (msg: string) => void): void } /** Extracts the common base fields shared by all tool call lifecycle states. */ -function tcBase(tc: IToolCallState) { +function tcBase(tc: ToolCallState) { return { toolCallId: tc.toolCallId, toolName: tc.toolName, @@ -36,8 +36,16 @@ function tcBase(tc: IToolCallState) { }; } +/** Resolves a selected option from the confirmation options array by ID. */ +function resolveSelectedOption(options: ConfirmationOption[] | undefined, id: string | undefined): ConfirmationOption | undefined { + if (!id || !options) { + return undefined; + } + return options.find(o => o.id === id); +} + /** Returns `true` if the active turn has any tool call awaiting user confirmation. */ -function hasPendingToolCallConfirmation(state: ISessionState): boolean { +function hasPendingToolCallConfirmation(state: SessionState): boolean { if (!state.activeTurn) { return false; } @@ -49,7 +57,7 @@ function hasPendingToolCallConfirmation(state: ISessionState): boolean { } /** Derives the summary status from live session work. */ -function summaryStatus(state: ISessionState, terminalStatus?: SessionStatus.Error): SessionStatus { +function summaryStatus(state: SessionState, terminalStatus?: SessionStatus.Error): SessionStatus { if (terminalStatus) { return terminalStatus; } @@ -67,7 +75,7 @@ function summaryStatus(state: ISessionState, terminalStatus?: SessionStatus.Erro * that change data which feeds into {@link summaryStatus} (e.g. tool call * lifecycle transitions that may enter or leave a pending-confirmation state). */ -function refreshSummaryStatus(state: ISessionState): ISessionState { +function refreshSummaryStatus(state: SessionState): SessionState { const status = summaryStatus(state); if (status === state.summary.status) { return state; @@ -82,18 +90,18 @@ function refreshSummaryStatus(state: ISessionState): ISessionState { * Pending permissions are stripped from tool call parts. */ function endTurn( - state: ISessionState, + state: SessionState, turnId: string, turnState: TurnState, terminalStatus?: SessionStatus.Error, error?: { errorType: string; message: string; stack?: string }, -): ISessionState { +): SessionState { if (!state.activeTurn || state.activeTurn.id !== turnId) { return state; } const active = state.activeTurn; - const responseParts: IResponsePart[] = active.responseParts.map(part => { + const responseParts: ResponsePart[] = active.responseParts.map(part => { if (part.kind !== ResponsePartKind.ToolCall) { return part; } @@ -114,7 +122,7 @@ function endTurn( }; }); - const turn: ITurn = { + const turn: Turn = { id: active.id, userMessage: active.userMessage, responseParts, @@ -123,7 +131,7 @@ function endTurn( error, }; - const next: ISessionState = { + const next: SessionState = { ...state, turns: [...state.turns, turn], activeTurn: undefined, @@ -136,7 +144,7 @@ function endTurn( }; } -function upsertInputRequest(state: ISessionState, request: ISessionInputRequest): ISessionState { +function upsertInputRequest(state: SessionState, request: SessionInputRequest): SessionState { const existing = state.inputRequests ?? []; const idx = existing.findIndex(r => r.id === request.id); const inputRequests = [...existing]; @@ -156,11 +164,11 @@ function upsertInputRequest(state: ISessionState, request: ISessionInputRequest) * active turn or tool call doesn't match. */ function updateToolCallInParts( - state: ISessionState, + state: SessionState, turnId: string, toolCallId: string, - updater: (tc: IToolCallState) => IToolCallState, -): ISessionState { + updater: (tc: ToolCallState) => ToolCallState, +): SessionState { const activeTurn = state.activeTurn; if (!activeTurn || activeTurn.id !== turnId) { return state; @@ -195,11 +203,11 @@ function updateToolCallInParts( * matches on `toolCall.toolCallId`. */ function updateResponsePart( - state: ISessionState, + state: SessionState, turnId: string, partId: string, - updater: (part: IResponsePart) => IResponsePart, -): ISessionState { + updater: (part: ResponsePart) => ResponsePart, +): SessionState { const activeTurn = state.activeTurn; if (!activeTurn || activeTurn.id !== turnId) { return state; @@ -232,9 +240,9 @@ function updateResponsePart( // ─── Root Reducer ──────────────────────────────────────────────────────────── /** - * Pure reducer for root state. Handles all {@link IRootAction} variants. + * Pure reducer for root state. Handles all {@link RootAction} variants. */ -export function rootReducer(state: IRootState, action: IRootAction, log?: (msg: string) => void): IRootState { +export function rootReducer(state: RootState, action: RootAction, log?: (msg: string) => void): RootState { switch (action.type) { case ActionType.RootAgentsChanged: return { ...state, agents: action.agents }; @@ -245,6 +253,18 @@ export function rootReducer(state: IRootState, action: IRootAction, log?: (msg: case ActionType.RootTerminalsChanged: return { ...state, terminals: action.terminals }; + case ActionType.RootConfigChanged: + if (!state.config) { + return state; + } + return { + ...state, + config: { + ...state.config, + values: action.replace ? { ...action.config } : { ...state.config.values, ...action.config }, + }, + }; + default: softAssertNever(action, log); return state; @@ -254,9 +274,9 @@ export function rootReducer(state: IRootState, action: IRootAction, log?: (msg: // ─── Session Reducer ───────────────────────────────────────────────────────── /** - * Pure reducer for session state. Handles all {@link ISessionAction} variants. + * Pure reducer for session state. Handles all {@link SessionAction} variants. */ -export function sessionReducer(state: ISessionState, action: ISessionAction, log?: (msg: string) => void): ISessionState { +export function sessionReducer(state: SessionState, action: SessionAction, log?: (msg: string) => void): SessionState { switch (action.type) { // ── Lifecycle ────────────────────────────────────────────────────────── @@ -277,7 +297,7 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log // ── Turn Lifecycle ──────────────────────────────────────────────────── case ActionType.SessionTurnStarted: { - let next: ISessionState = { + let next: SessionState = { ...state, activeTurn: { id: action.turnId, @@ -356,7 +376,7 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log _meta: action._meta, status: ToolCallStatus.Streaming, }, - } satisfies IToolCallResponsePart, + } satisfies ToolCallResponsePart, ], }, }; @@ -396,6 +416,7 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log confirmationTitle: action.confirmationTitle, edits: action.edits, editable: action.editable, + ...(action.options ? { options: action.options } : {}), }; })); @@ -405,6 +426,7 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log return tc; } const base = tcBase(tc); + const selectedOption = resolveSelectedOption(tc.options, action.selectedOptionId); if (action.approved) { return { status: ToolCallStatus.Running, @@ -412,6 +434,7 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log invocationMessage: tc.invocationMessage, toolInput: action.editedToolInput ?? tc.toolInput, confirmed: action.confirmed, + ...(selectedOption ? { selectedOption } : {}), }; } return { @@ -422,6 +445,7 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log reason: action.reason, reasonMessage: action.reasonMessage, userSuggestion: action.userSuggestion, + ...(selectedOption ? { selectedOption } : {}), }; })); @@ -434,6 +458,9 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log const confirmed = tc.status === ToolCallStatus.Running ? tc.confirmed : ToolCallConfirmationReason.NotNeeded; + const selectedOption = tc.status === ToolCallStatus.Running + ? tc.selectedOption + : undefined; if (action.requiresResultConfirmation) { return { status: ToolCallStatus.PendingResultConfirmation, @@ -441,6 +468,7 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log invocationMessage: tc.invocationMessage, toolInput: tc.toolInput, confirmed, + ...(selectedOption ? { selectedOption } : {}), ...action.result, }; } @@ -450,6 +478,7 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log invocationMessage: tc.invocationMessage, toolInput: tc.toolInput, confirmed, + ...(selectedOption ? { selectedOption } : {}), ...action.result, }; })); @@ -467,6 +496,7 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log invocationMessage: tc.invocationMessage, toolInput: tc.toolInput, confirmed: tc.confirmed, + ...(tc.selectedOption ? { selectedOption: tc.selectedOption } : {}), success: tc.success, pastTenseMessage: tc.pastTenseMessage, content: tc.content, @@ -480,6 +510,7 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log invocationMessage: tc.invocationMessage, toolInput: tc.toolInput, reason: ToolCallCancellationReason.ResultDenied, + ...(tc.selectedOption ? { selectedOption: tc.selectedOption } : {}), }; })); @@ -551,7 +582,7 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log ...state, config: { ...state.config, - values: { ...state.config.values, ...action.config }, + values: action.replace ? { ...action.config } : { ...state.config.values, ...action.config }, }, summary: { ...state.summary, @@ -609,7 +640,7 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log } turns = state.turns.slice(0, idx + 1); } - const next: ISessionState = { + const next: SessionState = { ...state, turns, activeTurn: undefined, @@ -658,7 +689,7 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log return state; } const inputRequests = existing.filter(request => request.id !== action.requestId); - const next: ISessionState = { + const next: SessionState = { ...state, }; if (inputRequests.length > 0) { @@ -675,7 +706,7 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log // ── Pending Messages ────────────────────────────────────────────────── case ActionType.SessionPendingMessageSet: { - const entry: IPendingMessage = { id: action.id, userMessage: action.userMessage }; + const entry: PendingMessage = { id: action.id, userMessage: action.userMessage }; if (action.kind === PendingMessageKind.Steering) { return { ...state, steeringMessage: entry }; } @@ -740,9 +771,9 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log // ─── Terminal Reducer ──────────────────────────────────────────────────────── /** - * Pure reducer for terminal state. Handles all {@link ITerminalAction} variants. + * Pure reducer for terminal state. Handles all {@link TerminalAction} variants. */ -export function terminalReducer(state: ITerminalState, action: ITerminalAction, log?: (msg: string) => void): ITerminalState { +export function terminalReducer(state: TerminalState, action: TerminalAction, log?: (msg: string) => void): TerminalState { switch (action.type) { case ActionType.TerminalData: { const content = [...state.content]; @@ -784,7 +815,7 @@ export function terminalReducer(state: ITerminalState, action: ITerminalAction, return { ...state, supportsCommandDetection: true }; case ActionType.TerminalCommandExecuted: { - const part: ITerminalContentPart = { + const part: TerminalContentPart = { type: 'command', commandId: action.commandId, commandLine: action.commandLine, @@ -828,6 +859,6 @@ export function terminalReducer(state: ITerminalState, action: ITerminalAction, * Servers SHOULD call this to validate incoming `dispatchAction` requests * and reject any action the client is not allowed to originate. */ -export function isClientDispatchable(action: ISessionAction | ITerminalAction): action is IClientSessionAction | IClientTerminalAction { +export function isClientDispatchable(action: SessionAction | TerminalAction): action is ClientSessionAction | ClientTerminalAction { return IS_CLIENT_DISPATCHABLE[action.type]; } diff --git a/src/vs/platform/agentHost/common/state/protocol/state.ts b/src/vs/platform/agentHost/common/state/protocol/state.ts index 26c7b08227fc5..84d64a6e1b7b0 100644 --- a/src/vs/platform/agentHost/common/state/protocol/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/state.ts @@ -75,7 +75,7 @@ export interface Icon { * @category Authentication * @see {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728} */ -export interface IProtectedResourceMetadata { +export interface ProtectedResourceMetadata { /** * REQUIRED. The protected resource's resource identifier, a URL using the * `https` scheme with no fragment component (e.g. `"https://api.github.com"`). @@ -147,19 +147,21 @@ export const enum PolicyState { * * @category Root State */ -export interface IRootState { +export interface RootState { /** Available agent backends and their models */ - agents: IAgentInfo[]; + agents: AgentInfo[]; /** Number of active (non-disposed) sessions on the server */ activeSessions?: number; /** Known terminals on the server. Subscribe to individual terminal URIs for full state. */ - terminals?: ITerminalInfo[]; + terminals?: TerminalInfo[]; + /** Agent host configuration schema and current values */ + config?: RootConfigState; } /** * @category Root State */ -export interface IAgentInfo { +export interface AgentInfo { /** Agent provider ID (e.g. `'copilot'`) */ provider: string; /** Human-readable name */ @@ -167,7 +169,7 @@ export interface IAgentInfo { /** Description string */ description: string; /** Available models for this agent */ - models: ISessionModelInfo[]; + models: SessionModelInfo[]; /** * Protected resources this agent requires authentication for. * @@ -179,20 +181,20 @@ export interface IAgentInfo { * * @see {@link /specification/authentication | Authentication} */ - protectedResources?: IProtectedResourceMetadata[]; + protectedResources?: ProtectedResourceMetadata[]; /** * Customizations (Open Plugins) associated with this agent. * * Each entry is a reference to an [Open Plugins](https://open-plugins.com/) * plugin that the agent host can activate for sessions using this agent. */ - customizations?: ICustomizationRef[]; + customizations?: CustomizationRef[]; } /** * @category Root State */ -export interface ISessionModelInfo { +export interface SessionModelInfo { /** Model identifier */ id: string; /** Provider this model belongs to */ @@ -208,19 +210,19 @@ export interface ISessionModelInfo { /** * Configuration schema describing model-specific options (e.g. thinking * level). Clients present this as a form and pass the resolved values in - * {@link IModelSelection.config} when creating or changing sessions. + * {@link ModelSelection.config} when creating or changing sessions. */ - configSchema?: IConfigSchema; + configSchema?: ConfigSchema; } /** * A model selection: the chosen model ID together with any model-specific * configuration values whose keys correspond to the model's - * {@link ISessionModelInfo.configSchema}. + * {@link SessionModelInfo.configSchema}. * * @category Root State */ -export interface IModelSelection { +export interface ModelSelection { /** Model identifier */ id: string; /** Model-specific configuration values */ @@ -250,11 +252,11 @@ export const enum PendingMessageKind { * * @category Pending Message Types */ -export interface IPendingMessage { +export interface PendingMessage { /** Unique identifier for this pending message */ id: string; /** The message content */ - userMessage: IUserMessage; + userMessage: UserMessage; } // ─── Session State ─────────────────────────────────────────────────────────── @@ -291,36 +293,36 @@ export const enum SessionStatus { * * @category Session State */ -export interface ISessionState { +export interface SessionState { /** Lightweight session metadata */ - summary: ISessionSummary; + summary: SessionSummary; /** Session initialization state */ lifecycle: SessionLifecycle; /** Error details if creation failed */ - creationError?: IErrorInfo; + creationError?: ErrorInfo; /** Tools provided by the server (agent host) for this session */ - serverTools?: IToolDefinition[]; + serverTools?: ToolDefinition[]; /** The client currently providing tools and interactive capabilities to this session */ - activeClient?: ISessionActiveClient; + activeClient?: SessionActiveClient; /** Completed turns */ - turns: ITurn[]; + turns: Turn[]; /** Currently in-progress turn */ - activeTurn?: IActiveTurn; + activeTurn?: ActiveTurn; /** Message to inject into the current turn at a convenient point */ - steeringMessage?: IPendingMessage; + steeringMessage?: PendingMessage; /** Messages to send automatically as new turns after the current turn finishes */ - queuedMessages?: IPendingMessage[]; + queuedMessages?: PendingMessage[]; /** Requests for user input that are currently blocking or informing session progress */ - inputRequests?: ISessionInputRequest[]; + inputRequests?: SessionInputRequest[]; /** Session configuration schema and current values */ - config?: ISessionConfigState; + config?: SessionConfigState; /** * Server-provided customizations active in this session. * * Client-provided customizations are available on - * {@link ISessionActiveClient.customizations | activeClient.customizations}. + * {@link SessionActiveClient.customizations | activeClient.customizations}. */ - customizations?: ISessionCustomization[]; + customizations?: SessionCustomization[]; } /** @@ -331,15 +333,15 @@ export interface ISessionState { * * @category Session State */ -export interface ISessionActiveClient { +export interface SessionActiveClient { /** Client identifier (matches `clientId` from `initialize`) */ clientId: string; /** Human-readable client name (e.g. `"VS Code"`) */ displayName?: string; /** Tools this client provides to the session */ - tools: IToolDefinition[]; + tools: ToolDefinition[]; /** Customizations this client contributes to the session */ - customizations?: ICustomizationRef[]; + customizations?: CustomizationRef[]; } /** @@ -347,7 +349,7 @@ export interface ISessionActiveClient { * * @category Session State */ -export interface IProjectInfo { +export interface ProjectInfo { /** Project URI */ uri: URI; /** Human-readable project name */ @@ -357,7 +359,7 @@ export interface IProjectInfo { /** * @category Session State */ -export interface ISessionSummary { +export interface SessionSummary { /** Session URI */ resource: URI; /** Agent provider ID */ @@ -371,9 +373,9 @@ export interface ISessionSummary { /** Last modification timestamp */ modifiedAt: number; /** Server-owned project for this session */ - project?: IProjectInfo; + project?: ProjectInfo; /** Currently selected model */ - model?: IModelSelection; + model?: ModelSelection; /** The working directory URI for this session */ workingDirectory?: URI; /** Whether the client has viewed this session since its last modification */ @@ -381,71 +383,94 @@ export interface ISessionSummary { /** Whether the session has been marked as done by the client */ isDone?: boolean; /** Files changed during this session with diff statistics */ - diffs?: IFileEdit[]; + diffs?: FileEdit[]; } // ─── Config Schema Types ───────────────────────────────────────────────────── /** - * A JSON Schema-compatible string enum property descriptor with display extensions. + * A JSON Schema-compatible property descriptor with display extensions. * * Standard JSON Schema fields (`type`, `title`, `description`, `default`, * `enum`) allow validators to process the schema. Display extensions * (`enumLabels`, `enumDescriptions`) are parallel arrays that provide UI * metadata for each `enum` value. * - * This is the generic base type. See {@link ISessionConfigPropertySchema} for + * This is the generic base type. See {@link SessionConfigPropertySchema} for * session-specific extensions. * * @category Config Schema Types */ -export interface IConfigPropertySchema { - /** JSON Schema: property type. Only string enum properties are currently supported. */ - type: 'string'; +export interface ConfigPropertySchema { + /** JSON Schema: property type */ + type: 'string' | 'number' | 'boolean' | 'array' | 'object'; /** JSON Schema: human-readable label for the property */ title: string; /** JSON Schema: description / tooltip */ description?: string; /** JSON Schema: default value */ - default?: string; - /** JSON Schema: allowed values */ - enum: string[]; + default?: unknown; + /** JSON Schema: allowed values (typically used with `string` type) */ + enum?: string[]; /** Display extension: human-readable label per enum value (parallel array) */ enumLabels?: string[]; /** Display extension: description per enum value (parallel array) */ enumDescriptions?: string[]; /** JSON Schema: when `true`, the property is displayed but cannot be modified by the user */ readOnly?: boolean; + /** JSON Schema: schema for array items (used when `type` is `'array'`) */ + items?: ConfigPropertySchema; + /** JSON Schema: property descriptors for object properties (used when `type` is `'object'`) */ + properties?: Record; + /** JSON Schema: list of required property ids (used when `type` is `'object'`) */ + required?: string[]; } /** * A JSON Schema object describing available configuration properties. * - * This is the generic base type. See {@link ISessionConfigSchema} for + * This is the generic base type. See {@link SessionConfigSchema} for * session-specific usage. * * @category Config Schema Types */ -export interface IConfigSchema { +export interface ConfigSchema { /** JSON Schema: always `'object'` */ type: 'object'; /** JSON Schema: property descriptors keyed by property id */ - properties: Record; + properties: Record; /** JSON Schema: list of required property ids */ required?: string[]; } +// ─── Root Config Types ─────────────────────────────────────────────────────── + +/** + * Live agent-host configuration metadata. + * + * The schema describes the available configuration properties and the values + * contain the current value for each resolved property. + * + * @category Root State + */ +export interface RootConfigState { + /** JSON Schema describing available configuration properties */ + schema: ConfigSchema; + /** Current configuration values */ + values: Record; +} + // ─── Session Config Types ──────────────────────────────────────────────────── /** * A session configuration property descriptor. * - * Extends the generic {@link IConfigPropertySchema} with session-specific + * Extends the generic {@link ConfigPropertySchema} with session-specific * display extensions. * * @category Session Config Types */ -export interface ISessionConfigPropertySchema extends IConfigPropertySchema { +export interface SessionConfigPropertySchema extends ConfigPropertySchema { /** * Display extension: when `true`, the full set of allowed values is too large * to enumerate statically. The client SHOULD use `sessionConfigCompletions` @@ -462,11 +487,11 @@ export interface ISessionConfigPropertySchema extends IConfigPropertySchema { * * @category Session Config Types */ -export interface ISessionConfigSchema { +export interface SessionConfigSchema { /** JSON Schema: always `'object'` */ type: 'object'; /** JSON Schema: property descriptors keyed by property id */ - properties: Record; + properties: Record; /** JSON Schema: list of required property ids */ required?: string[]; } @@ -479,11 +504,11 @@ export interface ISessionConfigSchema { * * @category Session Config Types */ -export interface ISessionConfigState { +export interface SessionConfigState { /** JSON Schema describing available configuration properties */ - schema: ISessionConfigSchema; + schema: SessionConfigSchema; /** Current configuration values */ - values: Record; + values: Record; } // ─── Session Input Types ──────────────────────────────────────────────────── @@ -518,7 +543,7 @@ export const enum SessionInputQuestionKind { * * @category Session Input Types */ -export interface ISessionInputOption { +export interface SessionInputOption { /** Stable option identifier; for MCP enum values this is the enum string */ id: string; /** Display label */ @@ -529,7 +554,7 @@ export interface ISessionInputOption { recommended?: boolean; } -interface ISessionInputQuestionBase { +interface SessionInputQuestionBase { /** Stable question identifier used as the key in `answers` */ id: string; /** Short display title */ @@ -541,7 +566,7 @@ interface ISessionInputQuestionBase { } /** Text question within a session input request. */ -export interface ISessionInputTextQuestion extends ISessionInputQuestionBase { +export interface SessionInputTextQuestion extends SessionInputQuestionBase { kind: SessionInputQuestionKind.Text; /** Format hint for text questions, such as `email`, `uri`, `date`, or `date-time` */ format?: string; @@ -554,7 +579,7 @@ export interface ISessionInputTextQuestion extends ISessionInputQuestionBase { } /** Numeric question within a session input request. */ -export interface ISessionInputNumberQuestion extends ISessionInputQuestionBase { +export interface SessionInputNumberQuestion extends SessionInputQuestionBase { kind: SessionInputQuestionKind.Number | SessionInputQuestionKind.Integer; /** Minimum value */ min?: number; @@ -565,26 +590,26 @@ export interface ISessionInputNumberQuestion extends ISessionInputQuestionBase { } /** Boolean question within a session input request. */ -export interface ISessionInputBooleanQuestion extends ISessionInputQuestionBase { +export interface SessionInputBooleanQuestion extends SessionInputQuestionBase { kind: SessionInputQuestionKind.Boolean; /** Default boolean value */ defaultValue?: boolean; } /** Single-select question within a session input request. */ -export interface ISessionInputSingleSelectQuestion extends ISessionInputQuestionBase { +export interface SessionInputSingleSelectQuestion extends SessionInputQuestionBase { kind: SessionInputQuestionKind.SingleSelect; /** Options the user may select from */ - options: ISessionInputOption[]; + options: SessionInputOption[]; /** Whether the user may enter text instead of selecting an option */ allowFreeformInput?: boolean; } /** Multi-select question within a session input request. */ -export interface ISessionInputMultiSelectQuestion extends ISessionInputQuestionBase { +export interface SessionInputMultiSelectQuestion extends SessionInputQuestionBase { kind: SessionInputQuestionKind.MultiSelect; /** Options the user may select from */ - options: ISessionInputOption[]; + options: SessionInputOption[]; /** Whether the user may enter text in addition to selecting options */ allowFreeformInput?: boolean; /** Minimum selected item count */ @@ -598,11 +623,11 @@ export interface ISessionInputMultiSelectQuestion extends ISessionInputQuestionB * * @category Session Input Types */ -export type ISessionInputQuestion = ISessionInputTextQuestion - | ISessionInputNumberQuestion - | ISessionInputBooleanQuestion - | ISessionInputSingleSelectQuestion - | ISessionInputMultiSelectQuestion; +export type SessionInputQuestion = SessionInputTextQuestion + | SessionInputNumberQuestion + | SessionInputBooleanQuestion + | SessionInputSingleSelectQuestion + | SessionInputMultiSelectQuestion; /** * A live request for user input. @@ -613,7 +638,7 @@ export type ISessionInputQuestion = ISessionInputTextQuestion * * @category Session Input Types */ -export interface ISessionInputRequest { +export interface SessionInputRequest { /** Stable request identifier */ id: string; /** Display message for the request as a whole */ @@ -621,9 +646,9 @@ export interface ISessionInputRequest { /** URL the user should review or open, for URL-style elicitations */ url?: URI; /** Ordered questions to ask the user */ - questions?: ISessionInputQuestion[]; + questions?: SessionInputQuestion[]; /** Current draft or submitted answers, keyed by question ID */ - answers?: Record; + answers?: Record; } /** @@ -644,49 +669,49 @@ export const enum SessionInputAnswerValueKind { * * @category Session Input Types */ -export interface ISessionInputTextAnswerValue { +export interface SessionInputTextAnswerValue { kind: SessionInputAnswerValueKind.Text; value: string; } -export interface ISessionInputNumberAnswerValue { +export interface SessionInputNumberAnswerValue { kind: SessionInputAnswerValueKind.Number; value: number; } -export interface ISessionInputBooleanAnswerValue { +export interface SessionInputBooleanAnswerValue { kind: SessionInputAnswerValueKind.Boolean; value: boolean; } -export interface ISessionInputSelectedAnswerValue { +export interface SessionInputSelectedAnswerValue { kind: SessionInputAnswerValueKind.Selected; value: string; /** Free-form text entered instead of selecting an option */ freeformValues?: string[]; } -export interface ISessionInputSelectedManyAnswerValue { +export interface SessionInputSelectedManyAnswerValue { kind: SessionInputAnswerValueKind.SelectedMany; value: string[]; /** Free-form text entered in addition to selected options */ freeformValues?: string[]; } -export type ISessionInputAnswerValue = ISessionInputTextAnswerValue - | ISessionInputNumberAnswerValue - | ISessionInputBooleanAnswerValue - | ISessionInputSelectedAnswerValue - | ISessionInputSelectedManyAnswerValue; +export type SessionInputAnswerValue = SessionInputTextAnswerValue + | SessionInputNumberAnswerValue + | SessionInputBooleanAnswerValue + | SessionInputSelectedAnswerValue + | SessionInputSelectedManyAnswerValue; -export interface ISessionInputAnswered { +export interface SessionInputAnswered { /** Answer state */ state: SessionInputAnswerState.Draft | SessionInputAnswerState.Submitted; /** Answer value */ - value: ISessionInputAnswerValue; + value: SessionInputAnswerValue; } -export interface ISessionInputSkipped { +export interface SessionInputSkipped { /** Answer state */ state: SessionInputAnswerState.Skipped; /** Free-form reason or value captured while skipping, if any */ @@ -709,7 +734,7 @@ export const enum SessionInputAnswerState { * * @category Session Input Types */ -export type ISessionInputAnswer = ISessionInputAnswered | ISessionInputSkipped; +export type SessionInputAnswer = SessionInputAnswered | SessionInputSkipped; // ─── Turn Types ────────────────────────────────────────────────────────────── @@ -740,24 +765,24 @@ export const enum AttachmentType { * * @category Turn Types */ -export interface ITurn { +export interface Turn { /** Turn identifier */ id: string; /** The user's input */ - userMessage: IUserMessage; + userMessage: UserMessage; /** * All response content in stream order: text, tool calls, reasoning, and content refs. * * Consumers should derive display text by concatenating markdown parts, * and find tool calls by filtering for `ToolCall` parts. */ - responseParts: IResponsePart[]; + responseParts: ResponsePart[]; /** Token usage info */ - usage: IUsageInfo | undefined; + usage: UsageInfo | undefined; /** How the turn ended */ state: TurnState; /** Error details if state is `'error'` */ - error?: IErrorInfo; + error?: ErrorInfo; } /** @@ -765,35 +790,35 @@ export interface ITurn { * * @category Turn Types */ -export interface IActiveTurn { +export interface ActiveTurn { /** Turn identifier */ id: string; /** The user's input */ - userMessage: IUserMessage; + userMessage: UserMessage; /** * All response content in stream order: text, tool calls, reasoning, and content refs. * * Tool call parts include `pendingPermissions` when permissions are awaiting user approval. */ - responseParts: IResponsePart[]; + responseParts: ResponsePart[]; /** Token usage info */ - usage: IUsageInfo | undefined; + usage: UsageInfo | undefined; } /** * @category Turn Types */ -export interface IUserMessage { +export interface UserMessage { /** Message text */ text: string; /** File/selection attachments */ - attachments?: IMessageAttachment[]; + attachments?: MessageAttachment[]; } /** * @category Turn Types */ -export interface IMessageAttachment { +export interface MessageAttachment { /** Attachment type */ type: AttachmentType; /** File/directory path */ @@ -819,7 +844,7 @@ export const enum ResponsePartKind { /** * @category Response Parts */ -export interface IMarkdownResponsePart { +export interface MarkdownResponsePart { /** Discriminant */ kind: ResponsePartKind.Markdown; /** Part identifier, used by `session/delta` to target this part for content appends */ @@ -831,7 +856,7 @@ export interface IMarkdownResponsePart { /** * A reference to large content stored outside the state tree. */ -export interface IContentRef { +export interface ContentRef { /** Content URI */ uri: URI; /** Approximate size in bytes */ @@ -845,7 +870,7 @@ export interface IContentRef { * * @category Response Parts */ -export interface IResourceReponsePart extends IContentRef { +export interface ResourceReponsePart extends ContentRef { /** Discriminant */ kind: ResponsePartKind.ContentRef; } @@ -859,11 +884,11 @@ export interface IResourceReponsePart extends IContentRef { * * @category Response Parts */ -export interface IToolCallResponsePart { +export interface ToolCallResponsePart { /** Discriminant */ kind: ResponsePartKind.ToolCall; /** Full tool call lifecycle state */ - toolCall: IToolCallState; + toolCall: ToolCallState; } /** @@ -871,7 +896,7 @@ export interface IToolCallResponsePart { * * @category Response Parts */ -export interface IReasoningResponsePart { +export interface ReasoningResponsePart { /** Discriminant */ kind: ResponsePartKind.Reasoning; /** Part identifier, used by `session/reasoning` to target this part for content appends */ @@ -883,7 +908,7 @@ export interface IReasoningResponsePart { /** * @category Response Parts */ -export type IResponsePart = IMarkdownResponsePart | IResourceReponsePart | IToolCallResponsePart | IReasoningResponsePart; +export type ResponsePart = MarkdownResponsePart | ResourceReponsePart | ToolCallResponsePart | ReasoningResponsePart; // ─── Tool Call Types ───────────────────────────────────────────────────────── @@ -927,6 +952,40 @@ export const enum ToolCallCancellationReason { ResultDenied = 'result-denied', } +/** + * Whether a confirmation option represents an approval or denial action. + * + * @category Tool Call Types + */ +export const enum ConfirmationOptionKind { + Approve = 'approve', + Deny = 'deny', +} + +/** + * A confirmation option that the server offers for a tool call awaiting + * approval. Allows richer choices beyond simple approve/deny — for example, + * "Approve in this Session" or "Deny with reason." + * + * @category Tool Call Types + */ +export interface ConfirmationOption { + /** Unique identifier for the option, returned in the confirmed action */ + id: string; + /** Human-readable label displayed to the user */ + label: string; + /** Whether this option represents an approval or denial */ + kind: ConfirmationOptionKind; + /** + * Logical group number for visual categorisation. + * + * Clients SHOULD display options in the order they are defined and MAY + * use differing group numbers to insert dividers between logical clusters + * of options. + */ + group?: number; +} + /** * Metadata common to all tool call states. * @@ -937,7 +996,7 @@ export const enum ToolCallCancellationReason { * A future version may move these to a separate diagnostic channel or namespace them * more clearly. */ -interface IToolCallBase { +interface ToolCallBase { /** Unique tool call identifier */ toolCallId: string; /** Internal tool name (for debugging/logging) */ @@ -968,7 +1027,7 @@ interface IToolCallBase { * * @category Tool Call Types */ -interface IToolCallParameterFields { +interface ToolCallParameterFields { /** Message describing what the tool will do */ invocationMessage: StringOrMarkdown; /** Raw tool input */ @@ -980,7 +1039,7 @@ interface IToolCallParameterFields { * * @category Tool Call Types */ -export interface IToolCallResult { +export interface ToolCallResult { /** Whether the tool succeeded */ success: boolean; /** Past-tense description of what the tool did */ @@ -990,7 +1049,7 @@ export interface IToolCallResult { * * This mirrors the `content` field of MCP `CallToolResult`. */ - content?: IToolResultContent[]; + content?: ToolResultContent[]; /** * Optional structured result object. * @@ -1006,7 +1065,7 @@ export interface IToolCallResult { * * @category Tool Call Types */ -export interface IToolCallStreamingState extends IToolCallBase { +export interface ToolCallStreamingState extends ToolCallBase { status: ToolCallStatus.Streaming; /** Partial parameters accumulated so far */ partialInput?: string; @@ -1020,14 +1079,21 @@ export interface IToolCallStreamingState extends IToolCallBase { * * @category Tool Call Types */ -export interface IToolCallPendingConfirmationState extends IToolCallBase, IToolCallParameterFields { +export interface ToolCallPendingConfirmationState extends ToolCallBase, ToolCallParameterFields { status: ToolCallStatus.PendingConfirmation; /** Short title for the confirmation prompt (e.g. `"Run in terminal"`, `"Write file"`) */ confirmationTitle?: StringOrMarkdown; /** File edits that this tool call will perform, for preview before confirmation */ - edits?: { items: IFileEdit[] }; + edits?: { items: FileEdit[] }; /** Whether the agent host allows the client to edit the tool's input parameters before confirming */ editable?: boolean; + /** + * Options the server offers for this confirmation. When present, the client + * SHOULD render these instead of a plain approve/deny UI. Each option + * belongs to a {@link ConfirmationOptionGroup} so the client can still + * categorise the choices. + */ + options?: ConfirmationOption[]; } /** @@ -1035,17 +1101,19 @@ export interface IToolCallPendingConfirmationState extends IToolCallBase, IToolC * * @category Tool Call Types */ -export interface IToolCallRunningState extends IToolCallBase, IToolCallParameterFields { +export interface ToolCallRunningState extends ToolCallBase, ToolCallParameterFields { status: ToolCallStatus.Running; /** How the tool was confirmed for execution */ confirmed: ToolCallConfirmationReason; + /** The confirmation option the user selected, if confirmation options were provided */ + selectedOption?: ConfirmationOption; /** * Partial content produced while the tool is still executing. * * For example, a terminal content block lets clients subscribe to live * output before the tool completes. */ - content?: IToolResultContent[]; + content?: ToolResultContent[]; } /** @@ -1053,10 +1121,12 @@ export interface IToolCallRunningState extends IToolCallBase, IToolCallParameter * * @category Tool Call Types */ -export interface IToolCallPendingResultConfirmationState extends IToolCallBase, IToolCallParameterFields, IToolCallResult { +export interface ToolCallPendingResultConfirmationState extends ToolCallBase, ToolCallParameterFields, ToolCallResult { status: ToolCallStatus.PendingResultConfirmation; /** How the tool was confirmed for execution */ confirmed: ToolCallConfirmationReason; + /** The confirmation option the user selected, if confirmation options were provided */ + selectedOption?: ConfirmationOption; } /** @@ -1064,10 +1134,12 @@ export interface IToolCallPendingResultConfirmationState extends IToolCallBase, * * @category Tool Call Types */ -export interface IToolCallCompletedState extends IToolCallBase, IToolCallParameterFields, IToolCallResult { +export interface ToolCallCompletedState extends ToolCallBase, ToolCallParameterFields, ToolCallResult { status: ToolCallStatus.Completed; /** How the tool was confirmed for execution */ confirmed: ToolCallConfirmationReason; + /** The confirmation option the user selected, if confirmation options were provided */ + selectedOption?: ConfirmationOption; } /** @@ -1075,14 +1147,16 @@ export interface IToolCallCompletedState extends IToolCallBase, IToolCallParamet * * @category Tool Call Types */ -export interface IToolCallCancelledState extends IToolCallBase, IToolCallParameterFields { +export interface ToolCallCancelledState extends ToolCallBase, ToolCallParameterFields { status: ToolCallStatus.Cancelled; /** Why the tool was cancelled */ reason: ToolCallCancellationReason; /** Optional message explaining the cancellation */ reasonMessage?: StringOrMarkdown; /** What the user suggested doing instead */ - userSuggestion?: IUserMessage; + userSuggestion?: UserMessage; + /** The confirmation option the user selected, if confirmation options were provided */ + selectedOption?: ConfirmationOption; } /** @@ -1093,13 +1167,13 @@ export interface IToolCallCancelledState extends IToolCallBase, IToolCallParamet * * @category Tool Call Types */ -export type IToolCallState = - | IToolCallStreamingState - | IToolCallPendingConfirmationState - | IToolCallRunningState - | IToolCallPendingResultConfirmationState - | IToolCallCompletedState - | IToolCallCancelledState; +export type ToolCallState = + | ToolCallStreamingState + | ToolCallPendingConfirmationState + | ToolCallRunningState + | ToolCallPendingResultConfirmationState + | ToolCallCompletedState + | ToolCallCancelledState; // ─── Tool Definition Types ─────────────────────────────────────────────────── @@ -1108,7 +1182,7 @@ export type IToolCallState = * * @category Tool Definition Types */ -export interface IToolDefinition { +export interface ToolDefinition { /** Unique tool identifier */ name: string; /** Human-readable display name */ @@ -1137,7 +1211,7 @@ export interface IToolDefinition { required?: string[]; }; /** Behavioral hints about the tool. All properties are advisory. */ - annotations?: IToolAnnotations; + annotations?: ToolAnnotations; /** * Additional provider-specific metadata. * @@ -1154,7 +1228,7 @@ export interface IToolDefinition { * * @category Tool Definition Types */ -export interface IToolAnnotations { +export interface ToolAnnotations { /** Alternate human-readable title */ title?: string; /** Tool does not modify its environment (default: false) */ @@ -1190,7 +1264,7 @@ export const enum ToolResultContentType { * * @category Tool Result Content */ -export interface IToolResultTextContent { +export interface ToolResultTextContent { type: ToolResultContentType.Text; /** The text content */ text: string; @@ -1203,7 +1277,7 @@ export interface IToolResultTextContent { * * @category Tool Result Content */ -export interface IToolResultEmbeddedResourceContent { +export interface ToolResultEmbeddedResourceContent { type: ToolResultContentType.EmbeddedResource; /** Base64-encoded data */ data: string; @@ -1214,11 +1288,11 @@ export interface IToolResultEmbeddedResourceContent { /** * A reference to a resource stored outside the tool result. * - * Wraps {@link IContentRef} for lazy-loading large results. + * Wraps {@link ContentRef} for lazy-loading large results. * * @category Tool Result Content */ -export interface IToolResultResourceContent extends IContentRef { +export interface ToolResultResourceContent extends ContentRef { type: ToolResultContentType.Resource; } @@ -1230,20 +1304,20 @@ export interface IToolResultResourceContent extends IContentRef { * * @category Tool Result Content */ -export interface IFileEdit { +export interface FileEdit { /** The file state before the edit. Absent for file creations or for in-place file edits. */ before?: { /** URI of the file before the edit */ uri: URI; /** Reference to the file content before the edit */ - content: IContentRef; + content: ContentRef; }; /** The file state after the edit. Absent for file deletions. */ after?: { /** URI of the file after the edit */ uri: URI; /** Reference to the file content after the edit */ - content: IContentRef; + content: ContentRef; }; /** Optional diff display metadata */ diff?: { @@ -1259,7 +1333,7 @@ export interface IFileEdit { * * @category Tool Result Content */ -export interface IToolResultFileEditContent extends IFileEdit { +export interface ToolResultFileEditContent extends FileEdit { type: ToolResultContentType.FileEdit; } @@ -1271,7 +1345,7 @@ export interface IToolResultFileEditContent extends IFileEdit { * * @category Tool Result Content */ -export interface IToolResultTerminalContent { +export interface ToolResultTerminalContent { type: ToolResultContentType.Terminal; /** Terminal URI (subscribable for full terminal state) */ resource: URI; @@ -1287,7 +1361,7 @@ export interface IToolResultTerminalContent { * * @category Tool Result Content */ -export interface IToolResultSubagentContent { +export interface ToolResultSubagentContent { type: ToolResultContentType.Subagent; /** Subagent session URI (subscribable for full session state) */ resource: URI; @@ -1303,20 +1377,20 @@ export interface IToolResultSubagentContent { * Content block in a tool result. * * Mirrors the content blocks in MCP `CallToolResult.content`, plus - * `IToolResultResourceContent` for lazy-loading large results, - * `IToolResultFileEditContent` for file edit diffs, - * `IToolResultTerminalContent` for live terminal output, and - * `IToolResultSubagentContent` for subagent sessions (AHP extensions). + * `ToolResultResourceContent` for lazy-loading large results, + * `ToolResultFileEditContent` for file edit diffs, + * `ToolResultTerminalContent` for live terminal output, and + * `ToolResultSubagentContent` for subagent sessions (AHP extensions). * * @category Tool Result Content */ -export type IToolResultContent = - | IToolResultTextContent - | IToolResultEmbeddedResourceContent - | IToolResultResourceContent - | IToolResultFileEditContent - | IToolResultTerminalContent - | IToolResultSubagentContent; +export type ToolResultContent = + | ToolResultTextContent + | ToolResultEmbeddedResourceContent + | ToolResultResourceContent + | ToolResultFileEditContent + | ToolResultTerminalContent + | ToolResultSubagentContent; // ─── Customization Types ───────────────────────────────────────────────────── @@ -1328,7 +1402,7 @@ export type IToolResultContent = * * @category Customization Types */ -export interface ICustomizationRef { +export interface CustomizationRef { /** Plugin URI (e.g. an HTTPS URL or marketplace identifier) */ uri: URI; /** Human-readable name */ @@ -1371,9 +1445,9 @@ export const enum CustomizationStatus { * * @category Customization Types */ -export interface ISessionCustomization { +export interface SessionCustomization { /** The plugin this customization refers to */ - customization: ICustomizationRef; + customization: CustomizationRef; /** Whether this customization is currently enabled */ enabled: boolean; /** Server-reported loading status */ @@ -1391,13 +1465,13 @@ export interface ISessionCustomization { * * @category Terminal Types */ -export interface ITerminalInfo { +export interface TerminalInfo { /** Terminal URI (subscribable for full terminal state) */ resource: URI; /** Human-readable terminal title */ title: string; /** Who currently holds this terminal */ - claim: ITerminalClaim; + claim: TerminalClaim; /** Process exit code, if the terminal process has exited */ exitCode?: number; } @@ -1417,7 +1491,7 @@ export const enum TerminalClaimKind { * * @category Terminal Types */ -export interface ITerminalClientClaim { +export interface TerminalClientClaim { /** Discriminant */ kind: TerminalClaimKind.Client; /** The `clientId` of the claiming client */ @@ -1429,7 +1503,7 @@ export interface ITerminalClientClaim { * * @category Terminal Types */ -export interface ITerminalSessionClaim { +export interface TerminalSessionClaim { /** Discriminant */ kind: TerminalClaimKind.Session; /** Session URI that claimed the terminal */ @@ -1446,14 +1520,14 @@ export interface ITerminalSessionClaim { * * @category Terminal Types */ -export type ITerminalClaim = ITerminalClientClaim | ITerminalSessionClaim; +export type TerminalClaim = TerminalClientClaim | TerminalSessionClaim; /** * Full state for a single terminal, loaded when a client subscribes to the terminal's URI. * * @category Terminal Types */ -export interface ITerminalState { +export interface TerminalState { /** Human-readable terminal title */ title: string; /** Current working directory of the terminal process */ @@ -1470,11 +1544,11 @@ export interface ITerminalState { * * Consumers that need command boundaries can filter by part type. */ - content: ITerminalContentPart[]; + content: TerminalContentPart[]; /** Process exit code, set when the terminal process exits */ exitCode?: number; /** Who currently holds this terminal */ - claim: ITerminalClaim; + claim: TerminalClaim; /** * Whether this terminal emits `terminal/commandExecuted` and * `terminal/commandFinished` actions and populates `command`-typed parts. @@ -1493,9 +1567,9 @@ export interface ITerminalState { * * @category Terminal Types */ -export type ITerminalContentPart = - | ITerminalUnclassifiedPart - | ITerminalCommandPart; +export type TerminalContentPart = + | TerminalUnclassifiedPart + | TerminalCommandPart; /** * Unstructured terminal output — content before, between, or after commands, @@ -1503,7 +1577,7 @@ export type ITerminalContentPart = * * @category Terminal Types */ -export interface ITerminalUnclassifiedPart { +export interface TerminalUnclassifiedPart { type: 'unclassified'; /** Accumulated VT output. Appended to by `terminal/data` when no command is executing. */ value: string; @@ -1518,7 +1592,7 @@ export interface ITerminalUnclassifiedPart { * * @category Terminal Types */ -export interface ITerminalCommandPart { +export interface TerminalCommandPart { type: 'command'; /** * Stable id matching the `commandId` on the corresponding @@ -1547,7 +1621,7 @@ export interface ITerminalCommandPart { /** * @category Common Types */ -export interface IUsageInfo { +export interface UsageInfo { /** Input tokens consumed */ inputTokens?: number; /** Output tokens generated */ @@ -1561,7 +1635,7 @@ export interface IUsageInfo { /** * @category Common Types */ -export interface IErrorInfo { +export interface ErrorInfo { /** Error type identifier */ errorType: string; /** Human-readable error message */ @@ -1576,11 +1650,11 @@ export interface IErrorInfo { * * @category Common Types */ -export interface ISnapshot { +export interface Snapshot { /** The subscribed resource URI (e.g. `agenthost:/root` or `copilot:/`) */ resource: URI; /** The current state of the resource */ - state: IRootState | ISessionState | ITerminalState; + state: RootState | SessionState | TerminalState; /** The `serverSeq` at which this snapshot was taken. Subsequent actions will have `serverSeq > fromSeq`. */ fromSeq: number; } diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index 92ac85534ce2b..f91870001ebbd 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -6,8 +6,8 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import { ActionType, type IStateAction } from '../actions.js'; -import { NotificationType, type IProtocolNotification } from '../notifications.js'; +import { ActionType, type StateAction } from '../actions.js'; +import { NotificationType, type ProtocolNotification } from '../notifications.js'; // ─── Protocol Version Constants ────────────────────────────────────────────── @@ -21,9 +21,9 @@ export const MIN_PROTOCOL_VERSION = 1; /** * Maps every action type to the protocol version that introduced it. - * Adding a new action to `IStateAction` without adding it here is a compile error. + * Adding a new action to `StateAction` without adding it here is a compile error. */ -export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: number } = { +export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: number } = { [ActionType.RootAgentsChanged]: 1, [ActionType.RootActiveSessionsChanged]: 1, [ActionType.SessionReady]: 1, @@ -62,6 +62,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: numbe [ActionType.SessionDiffsChanged]: 1, [ActionType.SessionConfigChanged]: 1, [ActionType.RootTerminalsChanged]: 1, + [ActionType.RootConfigChanged]: 1, [ActionType.TerminalData]: 1, [ActionType.TerminalInput]: 1, [ActionType.TerminalResized]: 1, @@ -78,7 +79,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: numbe /** * Returns whether the given action type is known to the specified protocol version. */ -export function isActionKnownToVersion(action: IStateAction, clientVersion: number): boolean { +export function isActionKnownToVersion(action: StateAction, clientVersion: number): boolean { return ACTION_INTRODUCED_IN[action.type] <= clientVersion; } @@ -86,10 +87,10 @@ export function isActionKnownToVersion(action: IStateAction, clientVersion: numb /** * Maps every notification type to the protocol version that introduced it. - * Adding a new notification to `IProtocolNotification` without adding it here + * Adding a new notification to `ProtocolNotification` without adding it here * is a compile error. */ -export const NOTIFICATION_INTRODUCED_IN: { readonly [K in IProtocolNotification['type']]: number } = { +export const NOTIFICATION_INTRODUCED_IN: { readonly [K in ProtocolNotification['type']]: number } = { [NotificationType.SessionAdded]: 1, [NotificationType.SessionRemoved]: 1, [NotificationType.SessionSummaryChanged]: 1, @@ -99,7 +100,7 @@ export const NOTIFICATION_INTRODUCED_IN: { readonly [K in IProtocolNotification[ /** * Returns whether the given notification type is known to the specified protocol version. */ -export function isNotificationKnownToVersion(notification: IProtocolNotification, clientVersion: number): boolean { +export function isNotificationKnownToVersion(notification: ProtocolNotification, clientVersion: number): boolean { return NOTIFICATION_INTRODUCED_IN[notification.type] <= clientVersion; } diff --git a/src/vs/platform/agentHost/common/state/sessionActions.ts b/src/vs/platform/agentHost/common/state/sessionActions.ts index fe626c091c6a6..469e46dff5ebe 100644 --- a/src/vs/platform/agentHost/common/state/sessionActions.ts +++ b/src/vs/platform/agentHost/common/state/sessionActions.ts @@ -9,146 +9,146 @@ // VS Code-specific additions: // - IToolCallStartAction extends protocol with `toolKind` and `language` // - isRootAction / isSessionAction type guards -// - INotification alias for IProtocolNotification +// - INotification alias for ProtocolNotification // ---- Re-exports from protocol ----------------------------------------------- export { ActionType, - type IActionEnvelope, - type IActionOrigin, - type IRootAgentsChangedAction, - type IRootActiveSessionsChangedAction, - type ISessionCreationFailedAction, - type ISessionDeltaAction, - type ISessionDiffsChangedAction, - type ISessionErrorAction, - type ISessionModelChangedAction, - type ISessionReadyAction, - type ISessionReasoningAction, - type ISessionResponsePartAction, - type ISessionToolCallCompleteAction, - type ISessionToolCallConfirmedAction, - type ISessionToolCallApprovedAction, - type ISessionToolCallDeniedAction, - type ISessionToolCallDeltaAction, - type ISessionToolCallReadyAction, - type ISessionToolCallResultConfirmedAction, - type ISessionToolCallStartAction, - type ISessionTitleChangedAction, - type ISessionTurnCancelledAction, - type ISessionTurnCompleteAction, - type ISessionTurnStartedAction, - type ISessionUsageAction, - type ISessionServerToolsChangedAction, - type ISessionActiveClientChangedAction, - type ISessionActiveClientToolsChangedAction, - type ISessionCustomizationsChangedAction, - type ISessionCustomizationToggledAction, - type ISessionPendingMessageSetAction, - type ISessionPendingMessageRemovedAction, - type ISessionQueuedMessagesReorderedAction, - type ISessionInputRequestedAction, - type ISessionInputCompletedAction, - type ISessionIsReadChangedAction, - type ISessionIsDoneChangedAction, - type ISessionToolCallContentChangedAction, - type IStateAction, + type ActionEnvelope, + type ActionOrigin, + type RootAgentsChangedAction, + type RootActiveSessionsChangedAction, + type SessionCreationFailedAction, + type SessionDeltaAction, + type SessionDiffsChangedAction, + type SessionErrorAction, + type SessionModelChangedAction, + type SessionReadyAction, + type SessionReasoningAction, + type SessionResponsePartAction, + type SessionToolCallCompleteAction, + type SessionToolCallConfirmedAction, + type SessionToolCallApprovedAction, + type SessionToolCallDeniedAction, + type SessionToolCallDeltaAction, + type SessionToolCallReadyAction, + type SessionToolCallResultConfirmedAction, + type SessionToolCallStartAction, + type SessionTitleChangedAction, + type SessionTurnCancelledAction, + type SessionTurnCompleteAction, + type SessionTurnStartedAction, + type SessionUsageAction, + type SessionServerToolsChangedAction, + type SessionActiveClientChangedAction, + type SessionActiveClientToolsChangedAction, + type SessionCustomizationsChangedAction, + type SessionCustomizationToggledAction, + type SessionPendingMessageSetAction, + type SessionPendingMessageRemovedAction, + type SessionQueuedMessagesReorderedAction, + type SessionInputRequestedAction, + type SessionInputCompletedAction, + type SessionIsReadChangedAction, + type SessionIsDoneChangedAction, + type SessionToolCallContentChangedAction, + type StateAction, } from './protocol/actions.js'; export { NotificationType, AuthRequiredReason, - type ISessionAddedNotification, - type ISessionRemovedNotification, - type IAuthRequiredNotification, + type SessionAddedNotification, + type SessionRemovedNotification, + type AuthRequiredNotification, } from './protocol/notifications.js'; // ---- Local aliases for short names ------------------------------------------ // Consumers use these shorter names; they're type-only aliases. import type { - IRootAgentsChangedAction, - IRootActiveSessionsChangedAction, - ISessionDeltaAction, - ISessionModelChangedAction, - ISessionReasoningAction, - ISessionResponsePartAction, - ISessionToolCallApprovedAction, - ISessionToolCallCompleteAction, - ISessionToolCallConfirmedAction, - ISessionToolCallDeniedAction, - ISessionToolCallDeltaAction, - ISessionToolCallReadyAction, - ISessionToolCallResultConfirmedAction, - ISessionToolCallStartAction, - ISessionTitleChangedAction, - ISessionTurnCancelledAction, - ISessionTurnCompleteAction, - ISessionTurnStartedAction, - ISessionUsageAction, - IStateAction, - ISessionPendingMessageSetAction, - ISessionPendingMessageRemovedAction, - ISessionQueuedMessagesReorderedAction, - ISessionIsReadChangedAction, - ISessionIsDoneChangedAction, + RootAgentsChangedAction, + RootActiveSessionsChangedAction, + SessionDeltaAction, + SessionModelChangedAction, + SessionReasoningAction, + SessionResponsePartAction, + SessionToolCallApprovedAction, + SessionToolCallCompleteAction, + SessionToolCallConfirmedAction, + SessionToolCallDeniedAction, + SessionToolCallDeltaAction, + SessionToolCallReadyAction, + SessionToolCallResultConfirmedAction, + SessionToolCallStartAction, + SessionTitleChangedAction, + SessionTurnCancelledAction, + SessionTurnCompleteAction, + SessionTurnStartedAction, + SessionUsageAction, + StateAction, + SessionPendingMessageSetAction, + SessionPendingMessageRemovedAction, + SessionQueuedMessagesReorderedAction, + SessionIsReadChangedAction, + SessionIsDoneChangedAction, } from './protocol/actions.js'; -import type { IProtocolNotification } from './protocol/notifications.js'; -import type { IRootAction as IRootAction_, ISessionAction as ISessionAction_, IClientSessionAction as IClientSessionAction_, IServerSessionAction as IServerSessionAction_, ITerminalAction as ITerminalAction_, IClientTerminalAction as IClientTerminalAction_ } from './protocol/action-origin.generated.js'; +import type { ProtocolNotification } from './protocol/notifications.js'; +import type { RootAction as IRootAction_, SessionAction as ISessionAction_, ClientSessionAction as IClientSessionAction_, ServerSessionAction as IServerSessionAction_, TerminalAction as ITerminalAction_, ClientTerminalAction as IClientTerminalAction_ } from './protocol/action-origin.generated.js'; -export type IRootAction = IRootAction_; -export type ISessionAction = ISessionAction_; -export type IClientSessionAction = IClientSessionAction_; -export type IServerSessionAction = IServerSessionAction_; -export type ITerminalAction = ITerminalAction_; -export type IClientTerminalAction = IClientTerminalAction_; +export type RootAction = IRootAction_; +export type SessionAction = ISessionAction_; +export type ClientSessionAction = IClientSessionAction_; +export type ServerSessionAction = IServerSessionAction_; +export type TerminalAction = ITerminalAction_; +export type ClientTerminalAction = IClientTerminalAction_; // Root actions -export type IAgentsChangedAction = IRootAgentsChangedAction; -export type IActiveSessionsChangedAction = IRootActiveSessionsChangedAction; +export type IAgentsChangedAction = RootAgentsChangedAction; +export type IActiveSessionsChangedAction = RootActiveSessionsChangedAction; // Session actions — short aliases -export type ITurnStartedAction = ISessionTurnStartedAction; -export type IDeltaAction = ISessionDeltaAction; -export type IResponsePartAction = ISessionResponsePartAction; -export type IToolCallStartAction = ISessionToolCallStartAction; -export type IToolCallDeltaAction = ISessionToolCallDeltaAction; -export type IToolCallReadyAction = ISessionToolCallReadyAction; -export type IToolCallApprovedAction = ISessionToolCallApprovedAction; -export type IToolCallDeniedAction = ISessionToolCallDeniedAction; -export type IToolCallConfirmedAction = ISessionToolCallConfirmedAction; -export type IToolCallCompleteAction = ISessionToolCallCompleteAction; -export type IToolCallResultConfirmedAction = ISessionToolCallResultConfirmedAction; -export type ITurnCompleteAction = ISessionTurnCompleteAction; -export type ITurnCancelledAction = ISessionTurnCancelledAction; -export type ITitleChangedAction = ISessionTitleChangedAction; -export type IUsageAction = ISessionUsageAction; -export type IReasoningAction = ISessionReasoningAction; -export type IModelChangedAction = ISessionModelChangedAction; -export type ICustomizationsChangedAction = import('./protocol/actions.js').ISessionCustomizationsChangedAction; -export type ICustomizationToggledAction = import('./protocol/actions.js').ISessionCustomizationToggledAction; - -export type IPendingMessageSetAction = ISessionPendingMessageSetAction; -export type IPendingMessageRemovedAction = ISessionPendingMessageRemovedAction; -export type IQueuedMessagesReorderedAction = ISessionQueuedMessagesReorderedAction; -export type IIsReadChangedAction = ISessionIsReadChangedAction; -export type IIsDoneChangedAction = ISessionIsDoneChangedAction; +export type ITurnStartedAction = SessionTurnStartedAction; +export type IDeltaAction = SessionDeltaAction; +export type IResponsePartAction = SessionResponsePartAction; +export type IToolCallStartAction = SessionToolCallStartAction; +export type IToolCallDeltaAction = SessionToolCallDeltaAction; +export type IToolCallReadyAction = SessionToolCallReadyAction; +export type IToolCallApprovedAction = SessionToolCallApprovedAction; +export type IToolCallDeniedAction = SessionToolCallDeniedAction; +export type IToolCallConfirmedAction = SessionToolCallConfirmedAction; +export type IToolCallCompleteAction = SessionToolCallCompleteAction; +export type IToolCallResultConfirmedAction = SessionToolCallResultConfirmedAction; +export type ITurnCompleteAction = SessionTurnCompleteAction; +export type ITurnCancelledAction = SessionTurnCancelledAction; +export type ITitleChangedAction = SessionTitleChangedAction; +export type IUsageAction = SessionUsageAction; +export type IReasoningAction = SessionReasoningAction; +export type IModelChangedAction = SessionModelChangedAction; +export type ICustomizationsChangedAction = import('./protocol/actions.js').SessionCustomizationsChangedAction; +export type ICustomizationToggledAction = import('./protocol/actions.js').SessionCustomizationToggledAction; + +export type IPendingMessageSetAction = SessionPendingMessageSetAction; +export type IPendingMessageRemovedAction = SessionPendingMessageRemovedAction; +export type IQueuedMessagesReorderedAction = SessionQueuedMessagesReorderedAction; +export type IIsReadChangedAction = SessionIsReadChangedAction; +export type IIsDoneChangedAction = SessionIsDoneChangedAction; // Notifications -export type INotification = IProtocolNotification; +export type INotification = ProtocolNotification; // ---- Type guards ------------------------------------------------------------ -export function isRootAction(action: IStateAction): action is IRootAction { +export function isRootAction(action: StateAction): action is RootAction { return action.type.startsWith('root/'); } -export function isSessionAction(action: IStateAction): action is ISessionAction { +export function isSessionAction(action: StateAction): action is SessionAction { return action.type.startsWith('session/'); } -export function isTerminalAction(action: IStateAction): action is ITerminalAction { +export function isTerminalAction(action: StateAction): action is TerminalAction { return action.type.startsWith('terminal/'); } diff --git a/src/vs/platform/agentHost/common/state/sessionProtocol.ts b/src/vs/platform/agentHost/common/state/sessionProtocol.ts index b093358ca6ae9..4f2bb9018163c 100644 --- a/src/vs/platform/agentHost/common/state/sessionProtocol.ts +++ b/src/vs/platform/agentHost/common/state/sessionProtocol.ts @@ -14,59 +14,59 @@ // JSON-RPC base types export type { - IJsonRpcErrorResponse, - IJsonRpcNotification, - IJsonRpcRequest, - IJsonRpcResponse, - IJsonRpcSuccessResponse, + JsonRpcErrorResponse, + JsonRpcNotification, + JsonRpcRequest, + JsonRpcResponse, + JsonRpcSuccessResponse, } from './protocol/messages.js'; // Typed message unions export type { - IAhpClientNotification, - IAhpNotification, - IAhpRequest, - IAhpResponse, - IAhpServerNotification, - IAhpSuccessResponse, - ICommandMap, - IClientNotificationMap, - INotificationMap, - INotificationMethodParams, - IProtocolMessage, - IServerNotificationMap, + AhpClientNotification, + AhpNotification, + AhpRequest, + AhpResponse, + AhpServerNotification, + AhpSuccessResponse, + CommandMap, + ClientNotificationMap, + NotificationMap, + NotificationMethodParams, + ProtocolMessage, + ServerNotificationMap, } from './protocol/messages.js'; // Command params and results export type { - ICreateSessionParams, - IDirectoryEntry, - IDispatchActionParams, - IDisposeSessionParams, - IFetchTurnsParams, - IFetchTurnsResult, - IInitializeParams, - IInitializeResult, - IListSessionsParams, - IListSessionsResult, - IReconnectParams, - IReconnectReplayResult, - IReconnectResult, - IReconnectSnapshotResult, - IResourceCopyParams, - IResourceCopyResult, - IResourceDeleteParams, - IResourceDeleteResult, - IResourceListParams, - IResourceListResult, - IResourceMoveParams, - IResourceMoveResult, - IResourceReadParams, - IResourceReadResult, - IResourceWriteParams, - IResourceWriteResult, - ISubscribeParams, - IUnsubscribeParams, + CreateSessionParams, + DirectoryEntry, + DispatchActionParams, + DisposeSessionParams, + FetchTurnsParams, + FetchTurnsResult, + InitializeParams, + InitializeResult, + ListSessionsParams, + ListSessionsResult, + ReconnectParams, + ReconnectReplayResult, + ReconnectResult, + ReconnectSnapshotResult, + ResourceCopyParams, + ResourceCopyResult, + ResourceDeleteParams, + ResourceDeleteResult, + ResourceListParams, + ResourceListResult, + ResourceMoveParams, + ResourceMoveResult, + ResourceReadParams, + ResourceReadResult, + ResourceWriteParams, + ResourceWriteResult, + SubscribeParams, + UnsubscribeParams, } from './protocol/commands.js'; export { ContentEncoding, ReconnectResultType } from './protocol/commands.js'; @@ -76,7 +76,7 @@ export { AhpErrorCodes, JsonRpcErrorCodes } from './protocol/errors.js'; export type { AhpErrorCode, JsonRpcErrorCode } from './protocol/errors.js'; // Snapshot type (re-exported from state) -export type { ISnapshot as IStateSnapshot } from './protocol/state.js'; +export type { Snapshot as IStateSnapshot } from './protocol/state.js'; // ---- Backward-compatible error code aliases --------------------------------- @@ -92,17 +92,17 @@ export const AHP_AUTH_REQUIRED = -32007 as const; // ---- Type guards ----------------------------------------------------------- -import type { IAhpRequest, IAhpNotification, IAhpSuccessResponse, IProtocolMessage, IJsonRpcErrorResponse } from './protocol/messages.js'; +import type { AhpRequest, AhpNotification, AhpSuccessResponse, ProtocolMessage, JsonRpcErrorResponse } from './protocol/messages.js'; -export function isJsonRpcRequest(msg: IProtocolMessage): msg is IAhpRequest { +export function isJsonRpcRequest(msg: ProtocolMessage): msg is AhpRequest { return 'method' in msg && 'id' in msg; } -export function isJsonRpcNotification(msg: IProtocolMessage): msg is IAhpNotification { +export function isJsonRpcNotification(msg: ProtocolMessage): msg is AhpNotification { return 'method' in msg && !('id' in msg); } -export function isJsonRpcResponse(msg: IProtocolMessage): msg is IAhpSuccessResponse | IJsonRpcErrorResponse { +export function isJsonRpcResponse(msg: ProtocolMessage): msg is AhpSuccessResponse | JsonRpcErrorResponse { return 'id' in msg && !('method' in msg); } diff --git a/src/vs/platform/agentHost/common/state/sessionReducers.ts b/src/vs/platform/agentHost/common/state/sessionReducers.ts index 78f174c3b1f21..0f8d992fd7061 100644 --- a/src/vs/platform/agentHost/common/state/sessionReducers.ts +++ b/src/vs/platform/agentHost/common/state/sessionReducers.ts @@ -9,13 +9,13 @@ // Re-export reducers from the protocol layer export { rootReducer, sessionReducer, softAssertNever, isClientDispatchable } from './protocol/reducers.js'; -import type { ICompletedToolCall, IToolCallState } from './sessionState.js'; +import type { ICompletedToolCall, ToolCallState } from './sessionState.js'; /** * Extracts the VS Code-specific `toolKind` hint from a tool call's `_meta` * bag. This is not part of the protocol and is injected by the agent adapter * (e.g. `copilotEventMapper`). */ -export function getToolKind(tc: IToolCallState | ICompletedToolCall): 'terminal' | 'subagent' | undefined { +export function getToolKind(tc: ToolCallState | ICompletedToolCall): 'terminal' | 'subagent' | undefined { return tc._meta?.toolKind as 'terminal' | 'subagent' | undefined; } diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index ae0aa043d66c3..59fbe08fce4fa 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -14,73 +14,73 @@ import { hasKey } from '../../../../base/common/types.js'; import { SessionLifecycle, ToolResultContentType, - IToolResultFileEditContent, - type IActiveTurn, - type IRootState, - type ISessionState, - type ISessionSummary, - type IToolCallCancelledState, - type IToolCallCompletedState, - type IToolCallResult, - type IToolCallState, - type IToolResultContent, - type IToolResultSubagentContent, - type IToolResultTextContent, - type IUserMessage, - ITerminalState, + ToolResultFileEditContent, + type ActiveTurn, + type RootState, + type SessionState, + type SessionSummary, + type ToolCallCancelledState, + type ToolCallCompletedState, + type ToolCallResult, + type ToolCallState, + type ToolResultContent, + type ToolResultSubagentContent, + type ToolResultTextContent, + type UserMessage, + TerminalState, } from './protocol/state.js'; // Re-export everything from the protocol state module export { - type IActiveTurn, - type IAgentInfo, - type IConfigPropertySchema, - type IConfigSchema, - type IContentRef, - type IErrorInfo, - type IProjectInfo, - type IMarkdownResponsePart, - type IMessageAttachment, - type IReasoningResponsePart, - type IResponsePart, - type IRootState, - type ISessionActiveClient, - type ISessionConfigState, - type IFileEdit as ISessionFileDiff, - type IModelSelection, - type ISessionModelInfo, - type ISessionState, - type ISessionSummary, - type ISnapshot, - type ITerminalState, - type IToolAnnotations, - type IToolCallCancelledState, - type IToolCallCompletedState, - type IToolCallPendingConfirmationState, - type IToolCallPendingResultConfirmationState, - type IToolCallResponsePart, - type IToolCallResult, - type IToolCallRunningState, - type IToolCallState, - type IToolCallStreamingState, - type IToolDefinition, - type ICustomizationRef, - type ISessionCustomization, - type IToolResultEmbeddedResourceContent as IToolResultBinaryContent, - type IToolResultContent, - type IToolResultFileEditContent, - type IToolResultSubagentContent, - type IToolResultTextContent, - type ITurn, - type IUsageInfo, - type IUserMessage, - type IPendingMessage, + type ActiveTurn, + type AgentInfo, + type ConfigPropertySchema, + type ConfigSchema, + type ContentRef, + type ErrorInfo, + type ProjectInfo, + type MarkdownResponsePart, + type MessageAttachment, + type ReasoningResponsePart, + type ResponsePart, + type RootState, + type SessionActiveClient, + type SessionConfigState, + type FileEdit as ISessionFileDiff, + type ModelSelection, + type SessionModelInfo, + type SessionState, + type SessionSummary, + type Snapshot, + type TerminalState, + type ToolAnnotations, + type ToolCallCancelledState, + type ToolCallCompletedState, + type ToolCallPendingConfirmationState, + type ToolCallPendingResultConfirmationState, + type ToolCallResponsePart, + type ToolCallResult, + type ToolCallRunningState, + type ToolCallState, + type ToolCallStreamingState, + type ToolDefinition, + type CustomizationRef, + type SessionCustomization, + type ToolResultEmbeddedResourceContent as IToolResultBinaryContent, + type ToolResultContent, + type ToolResultFileEditContent, + type ToolResultSubagentContent, + type ToolResultTextContent, + type Turn, + type UsageInfo, + type UserMessage, + type PendingMessage, type StringOrMarkdown, type URI, - type ISessionInputRequest, - type ISessionInputQuestion, - type ISessionInputAnswer, - type ISessionInputOption, + type SessionInputRequest, + type SessionInputQuestion, + type SessionInputAnswer, + type SessionInputOption, AttachmentType, CustomizationStatus, PendingMessageKind, @@ -103,7 +103,7 @@ export { /** * The kind of file edit operation. Derived from the presence/absence of - * `before`/`after` in {@link IToolResultFileEditContent}. + * `before`/`after` in {@link ToolResultFileEditContent}. */ export const enum FileEditKind { /** Content edit (same file URI, different content). */ @@ -126,12 +126,12 @@ export const ROOT_STATE_URI = 'agenthost:/root'; /** * A tool call in a terminal state, stored in completed turns. */ -export type ICompletedToolCall = IToolCallCompletedState | IToolCallCancelledState; +export type ICompletedToolCall = ToolCallCompletedState | ToolCallCancelledState; /** * Derived status type for the tool call lifecycle. */ -export type ToolCallStatusString = IToolCallState['status']; +export type ToolCallStatusString = ToolCallState['status']; // ---- Tool output helper ----------------------------------------------------- @@ -141,11 +141,11 @@ export type ToolCallStatusString = IToolCallState['status']; * * Returns `undefined` if there are no text content parts. */ -export function getToolOutputText(result: IToolCallResult): string | undefined { +export function getToolOutputText(result: ToolCallResult): string | undefined { if (!result.content || result.content.length === 0) { return undefined; } - const textParts: IToolResultTextContent[] = []; + const textParts: ToolResultTextContent[] = []; for (const c of result.content) { if (hasKey(c, { type: true }) && c.type === ToolResultContentType.Text) { textParts.push(c); @@ -161,11 +161,11 @@ export function getToolOutputText(result: IToolCallResult): string | undefined { * Extracts file edit content entries from a tool call result's `content` array. * Returns an empty array if there are no file edit content parts. */ -export function getToolFileEdits(result: IToolCallResult): IToolResultFileEditContent[] { +export function getToolFileEdits(result: ToolCallResult): ToolResultFileEditContent[] { if (!result.content || result.content.length === 0) { return []; } - const edits: IToolResultFileEditContent[] = []; + const edits: ToolResultFileEditContent[] = []; for (const c of result.content) { if (hasKey(c, { type: true }) && c.type === ToolResultContentType.FileEdit) { edits.push(c); @@ -179,13 +179,13 @@ export function getToolFileEdits(result: IToolCallResult): IToolResultFileEditCo * Works with both completed tool call results and running tool call states. * Returns `undefined` if there are no subagent content parts. */ -export function getToolSubagentContent(result: { content?: readonly IToolResultContent[] }): IToolResultSubagentContent | undefined { +export function getToolSubagentContent(result: { content?: readonly ToolResultContent[] }): ToolResultSubagentContent | undefined { if (!result.content || result.content.length === 0) { return undefined; } for (const c of result.content) { if (hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent) { - return c as IToolResultSubagentContent; + return c as ToolResultSubagentContent; } } return undefined; @@ -231,14 +231,14 @@ export function isSubagentSession(uri: string): boolean { // ---- Factory helpers -------------------------------------------------------- -export function createRootState(): IRootState { +export function createRootState(): RootState { return { agents: [], activeSessions: 0, }; } -export function createSessionState(summary: ISessionSummary): ISessionState { +export function createSessionState(summary: SessionSummary): SessionState { return { summary, lifecycle: SessionLifecycle.Creating, @@ -247,7 +247,7 @@ export function createSessionState(summary: ISessionSummary): ISessionState { }; } -export function createActiveTurn(id: string, userMessage: IUserMessage): IActiveTurn { +export function createActiveTurn(id: string, userMessage: UserMessage): ActiveTurn { return { id, userMessage, @@ -263,7 +263,7 @@ export const enum StateComponents { } export type ComponentToState = { - [StateComponents.Root]: IRootState; - [StateComponents.Session]: ISessionState; - [StateComponents.Terminal]: ITerminalState; + [StateComponents.Root]: RootState; + [StateComponents.Session]: SessionState; + [StateComponents.Terminal]: TerminalState; }; diff --git a/src/vs/platform/agentHost/common/state/sessionTransport.ts b/src/vs/platform/agentHost/common/state/sessionTransport.ts index 6710a1cd7fb0d..c41a45baba4a3 100644 --- a/src/vs/platform/agentHost/common/state/sessionTransport.ts +++ b/src/vs/platform/agentHost/common/state/sessionTransport.ts @@ -12,7 +12,7 @@ import { Event } from '../../../../base/common/event.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; -import type { IProtocolMessage, IAhpServerNotification, IJsonRpcNotification, IJsonRpcResponse, IJsonRpcRequest } from './sessionProtocol.js'; +import type { ProtocolMessage, AhpServerNotification, JsonRpcNotification, JsonRpcResponse, JsonRpcRequest } from './sessionProtocol.js'; /** * A bidirectional transport for protocol messages. Implementations handle @@ -20,7 +20,7 @@ import type { IProtocolMessage, IAhpServerNotification, IJsonRpcNotification, IJ */ export interface IProtocolTransport extends IDisposable { /** Fires when a message is received from the remote end. */ - readonly onMessage: Event; + readonly onMessage: Event; /** Fires when the transport connection closes. */ readonly onClose: Event; @@ -29,11 +29,11 @@ export interface IProtocolTransport extends IDisposable { * Send a message to the remote end. * * Accepts: - * - `IProtocolMessage` — fully-typed client↔server messages. - * - `IAhpServerNotification` — server→client notifications. - * - `IJsonRpcResponse` — dynamically-constructed success/error responses. + * - `ProtocolMessage` — fully-typed client↔server messages. + * - `AhpServerNotification` — server→client notifications. + * - `JsonRpcResponse` — dynamically-constructed success/error responses. */ - send(message: IProtocolMessage | IAhpServerNotification | IJsonRpcNotification | IJsonRpcResponse | IJsonRpcRequest): void; + send(message: ProtocolMessage | AhpServerNotification | JsonRpcNotification | JsonRpcResponse | JsonRpcRequest): void; } /** diff --git a/src/vs/platform/agentHost/electron-browser/agentHostService.ts b/src/vs/platform/agentHost/electron-browser/agentHostService.ts index 654b1f7d6471b..543e9c374d441 100644 --- a/src/vs/platform/agentHost/electron-browser/agentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/agentHostService.ts @@ -14,12 +14,12 @@ import { acquirePort } from '../../../base/parts/ipc/electron-browser/ipc.mp.js' import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentHostService, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IAgentHostSocketInfo, IConnectionTrackerService } from '../common/agentService.js'; +import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentHostService, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IAgentHostSocketInfo, IConnectionTrackerService } from '../common/agentService.js'; import { AgentSubscriptionManager, type IAgentSubscription } from '../common/state/agentSubscription.js'; -import type { ICreateTerminalParams, IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; -import type { IActionEnvelope, INotification, ISessionAction, ITerminalAction } from '../common/state/sessionActions.js'; -import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; -import { StateComponents, ROOT_STATE_URI, type IRootState } from '../common/state/sessionState.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 { 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'; import { URI } from '../../../base/common/uri.js'; @@ -45,7 +45,7 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { private readonly _onAgentHostStart = this._register(new Emitter()); readonly onAgentHostStart = this._onAgentHostStart.event; - private readonly _onDidAction = this._register(new Emitter()); + private readonly _onDidAction = this._register(new Emitter()); readonly onDidAction = this._onDidAction.event; private readonly _onDidNotification = this._register(new Emitter()); @@ -107,7 +107,7 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { this._clientEventually.complete(client); store.add(this._proxy.onDidAction(e => { - const revived = revive(e) as IActionEnvelope; + const revived = revive(e) as ActionEnvelope; this._subscriptionManager.receiveEnvelope(revived); this._onDidAction.fire(revived); })); @@ -119,7 +119,7 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { // Subscribe to root state this.subscribe(URI.parse(ROOT_STATE_URI)).then(snapshot => { - this._subscriptionManager.handleRootSnapshot(snapshot.state as IRootState, snapshot.fromSeq); + this._subscriptionManager.handleRootSnapshot(snapshot.state as RootState, snapshot.fromSeq); }).catch(err => { this._logService.error('[AgentHost:renderer] Failed to subscribe to root state', err); }); @@ -127,7 +127,7 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { // ---- IAgentService forwarding (no await needed, delayed channel handles queuing) ---- - authenticate(params: IAuthenticateParams): Promise { + authenticate(params: AuthenticateParams): Promise { return this._proxy.authenticate(params); } listSessions(): Promise { @@ -136,16 +136,16 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { createSession(config?: IAgentCreateSessionConfig): Promise { return this._proxy.createSession(config); } - resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { + resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { return this._proxy.resolveSessionConfig(params); } - sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { + sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { return this._proxy.sessionConfigCompletions(params); } disposeSession(session: URI): Promise { return this._proxy.disposeSession(session); } - createTerminal(params: ICreateTerminalParams): Promise { + createTerminal(params: CreateTerminalParams): Promise { return this._proxy.createTerminal(params); } disposeTerminal(terminal: URI): Promise { @@ -160,7 +160,7 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { unsubscribe(resource: URI): void { this._proxy.unsubscribe(resource); } - dispatchAction(action: ISessionAction | ITerminalAction, clientId: string, clientSeq: number): void { + dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void { this._proxy.dispatchAction(action, clientId, clientSeq); } private _nextSeq = 1; @@ -168,7 +168,7 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { return this._nextSeq++; } - get rootState(): IAgentSubscription { + get rootState(): IAgentSubscription { return this._subscriptionManager.rootState; } @@ -180,27 +180,27 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { return this._subscriptionManager.getSubscriptionUnmanaged(resource); } - dispatch(action: ISessionAction | ITerminalAction): void { + dispatch(action: SessionAction | TerminalAction): void { const seq = this._subscriptionManager.dispatchOptimistic(action); this.dispatchAction(action, this.clientId, seq); } - resourceList(uri: URI): Promise { + resourceList(uri: URI): Promise { return this._proxy.resourceList(uri); } - resourceRead(uri: URI): Promise { + resourceRead(uri: URI): Promise { return this._proxy.resourceRead(uri); } - resourceWrite(params: IResourceWriteParams): Promise { + resourceWrite(params: ResourceWriteParams): Promise { return this._proxy.resourceWrite(params); } - resourceCopy(params: IResourceCopyParams): Promise { + resourceCopy(params: ResourceCopyParams): Promise { return this._proxy.resourceCopy(params); } - resourceDelete(params: IResourceDeleteParams): Promise { + resourceDelete(params: ResourceDeleteParams): Promise { return this._proxy.resourceDelete(params); } - resourceMove(params: IResourceMoveParams): Promise { + resourceMove(params: ResourceMoveParams): Promise { return this._proxy.resourceMove(params); } async restartAgentHost(): Promise { diff --git a/src/vs/platform/agentHost/electron-browser/sshRelayTransport.ts b/src/vs/platform/agentHost/electron-browser/sshRelayTransport.ts index 88eea907e56b8..68549cc6eaa74 100644 --- a/src/vs/platform/agentHost/electron-browser/sshRelayTransport.ts +++ b/src/vs/platform/agentHost/electron-browser/sshRelayTransport.ts @@ -5,7 +5,7 @@ import { Emitter } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; -import type { IAhpServerNotification, IJsonRpcResponse, IProtocolMessage } from '../common/state/sessionProtocol.js'; +import type { AhpServerNotification, JsonRpcResponse, ProtocolMessage } from '../common/state/sessionProtocol.js'; import type { IProtocolTransport } from '../common/state/sessionTransport.js'; import type { ISSHRelayMessage, ISSHRemoteAgentHostMainService } from '../common/sshRemoteAgentHost.js'; @@ -18,7 +18,7 @@ import type { ISSHRelayMessage, ISSHRemoteAgentHostMainService } from '../common */ export class SSHRelayTransport extends Disposable implements IProtocolTransport { - private readonly _onMessage = this._register(new Emitter()); + private readonly _onMessage = this._register(new Emitter()); readonly onMessage = this._onMessage.event; private readonly _onClose = this._register(new Emitter()); @@ -34,7 +34,7 @@ export class SSHRelayTransport extends Disposable implements IProtocolTransport this._register(this._sshService.onDidRelayMessage((msg: ISSHRelayMessage) => { if (msg.connectionId === this._connectionId) { try { - const parsed = JSON.parse(msg.data) as IProtocolMessage; + const parsed = JSON.parse(msg.data) as ProtocolMessage; this._onMessage.fire(parsed); } catch { // Malformed message — drop @@ -50,7 +50,7 @@ export class SSHRelayTransport extends Disposable implements IProtocolTransport })); } - send(message: IProtocolMessage | IAhpServerNotification | IJsonRpcResponse): void { + send(message: ProtocolMessage | AhpServerNotification | JsonRpcResponse): void { this._sshService.relaySend(this._connectionId, JSON.stringify(message)).catch(() => { // Send failed — connection probably closed }); diff --git a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts index a24cbf0ce507f..644c7bf6f52fa 100644 --- a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { ILogService } from '../../log/common/log.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { ISharedProcessService } from '../../ipc/electron-browser/services.js'; @@ -18,6 +18,7 @@ import { SSH_REMOTE_AGENT_HOST_CHANNEL, type ISSHAgentHostConfig, type ISSHAgentHostConnection, + type ISSHConnectResult, type ISSHRemoteAgentHostMainService, type ISSHResolvedConfig, type ISSHConnectProgress, @@ -78,20 +79,59 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA const augmentedConfig = this._augmentConfig(config); const result = await this._mainService.connect(augmentedConfig); this._logService.trace('[SSHRemoteAgentHost] SSH tunnel established, connectionId=' + result.connectionId); + return this._setupConnection(result); + } + + async disconnect(host: string): Promise { + await this._mainService.disconnect(host); + } + + async listSSHConfigHosts(): Promise { + return this._mainService.listSSHConfigHosts(); + } + async resolveSSHConfig(host: string): Promise { + return this._mainService.resolveSSHConfig(host); + } + + async reconnect(sshConfigHost: string, name: string): Promise { + const commandOverride = this._getRemoteAgentHostCommand(); + const result = await this._mainService.reconnect(sshConfigHost, name, commandOverride); + return this._setupConnection(result); + } + + /** + * Build the renderer-side handle, do the protocol handshake, and register + * with IRemoteAgentHostService. Any failure after the shared-process tunnel + * was established tears it back down so we don't leak it. + */ + private async _setupConnection(result: ISSHConnectResult): Promise { const existing = this._connections.get(result.connectionId); if (existing) { this._logService.trace('[SSHRemoteAgentHost] Returning existing connection handle'); return existing; } - // Create relay transport + protocol client, then register with RemoteAgentHostService + let protocolClient: RemoteAgentHostProtocolClient | undefined; + let handle: SSHAgentHostConnectionHandle | undefined; + let registeredHandle = false; try { - const protocolClient = this._createRelayClient(result); + protocolClient = this._createRelayClient(result); await protocolClient.connect(); this._logService.trace('[SSHRemoteAgentHost] Protocol handshake completed'); - await this._remoteAgentHostService.addSSHConnection({ + handle = new SSHAgentHostConnectionHandle( + result.config, + result.address, + result.name, + () => this._mainService.disconnect(result.connectionId), + ); + + this._connections.set(result.connectionId, handle); + registeredHandle = true; + this._onDidChangeConnections.fire(); + + await this._remoteAgentHostService.addManagedConnection({ name: result.name, connectionToken: result.connectionToken, connection: { @@ -102,74 +142,46 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA user: result.config.username || undefined, port: result.config.port, }, - }, protocolClient); + }, protocolClient, this._createTransportDisposable(result.connectionId, handle)); + + return handle; } catch (err) { this._logService.error('[SSHRemoteAgentHost] Connection setup failed', err); + if (registeredHandle && this._connections.get(result.connectionId) === handle) { + this._connections.delete(result.connectionId); + this._onDidChangeConnections.fire(); + } + handle?.dispose(); + protocolClient?.dispose(); this._mainService.disconnect(result.connectionId).catch(() => { /* best effort */ }); throw err; } - - const handle = new SSHAgentHostConnectionHandle( - result.config, - result.address, - result.name, - () => this._mainService.disconnect(result.connectionId), - ); - - this._connections.set(result.connectionId, handle); - this._onDidChangeConnections.fire(); - - return handle; } - async disconnect(host: string): Promise { - await this._mainService.disconnect(host); - } - - async listSSHConfigHosts(): Promise { - return this._mainService.listSSHConfigHosts(); - } - - async resolveSSHConfig(host: string): Promise { - return this._mainService.resolveSSHConfig(host); - } - - async reconnect(sshConfigHost: string, name: string): Promise { - const commandOverride = this._getRemoteAgentHostCommand(); - const result = await this._mainService.reconnect(sshConfigHost, name, commandOverride); - - const existing = this._connections.get(result.connectionId); - if (existing) { - return existing; - } - - const protocolClient = this._createRelayClient(result); - await protocolClient.connect(); - - await this._remoteAgentHostService.addSSHConnection({ - name: result.name, - connectionToken: result.connectionToken, - connection: { - type: RemoteAgentHostEntryType.SSH, - address: result.address, - sshConfigHost: result.sshConfigHost, - hostName: result.config.host, - user: result.config.username || undefined, - port: result.config.port, - }, - }, protocolClient); - - const handle = new SSHAgentHostConnectionHandle( - result.config, - result.address, - result.name, - () => this._mainService.disconnect(result.connectionId), - ); - - this._connections.set(result.connectionId, handle); - this._onDidChangeConnections.fire(); - - return handle; + /** + * Build a disposable that the {@link IRemoteAgentHostService} will own + * for the lifetime of this entry. When the entry is removed (either by + * the user via "Remove Remote" or by config reconciliation), this runs + * and tears down the renderer-side handle and the shared-process SSH + * tunnel together. Without this hookup, the SSH tunnel would leak and + * the next `connect()` would silently reuse it. + */ + private _createTransportDisposable(connectionId: string, handle: SSHAgentHostConnectionHandle): IDisposable { + return toDisposable(() => { + // Drop the renderer-side handle map entry first so a concurrent + // `connect()` for the same key doesn't latch onto a being-torn-down + // connection. + if (this._connections.get(connectionId) === handle) { + this._connections.delete(connectionId); + this._onDidChangeConnections.fire(); + } + // Mark the handle as already closed-from-main so disposing it + // doesn't kick off a redundant second disconnect IPC. The actual + // disconnect is initiated below. + handle.fireClose(); + handle.dispose(); + this._mainService.disconnect(connectionId).catch(() => { /* best effort */ }); + }); } private _createRelayClient(result: { connectionId: string; address: string }): RemoteAgentHostProtocolClient { diff --git a/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts b/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts index bc9b5948a04ad..0043e90817e9c 100644 --- a/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts +++ b/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts @@ -5,7 +5,7 @@ import { Emitter } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; -import type { IAhpServerNotification, IJsonRpcResponse, IProtocolMessage } from '../common/state/sessionProtocol.js'; +import type { AhpServerNotification, JsonRpcResponse, ProtocolMessage } from '../common/state/sessionProtocol.js'; import type { IProtocolTransport } from '../common/state/sessionTransport.js'; import type { ITunnelAgentHostMainService, ITunnelRelayMessage } from '../common/tunnelAgentHost.js'; import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../common/transportConstants.js'; @@ -19,7 +19,7 @@ import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from */ export class TunnelRelayTransport extends Disposable implements IProtocolTransport { - private readonly _onMessage = this._register(new Emitter()); + private readonly _onMessage = this._register(new Emitter()); readonly onMessage = this._onMessage.event; private readonly _onClose = this._register(new Emitter()); @@ -38,9 +38,9 @@ export class TunnelRelayTransport extends Disposable implements IProtocolTranspo if (msg.connectionId !== this._connectionId) { return; } - let parsed: IProtocolMessage; + let parsed: ProtocolMessage; try { - parsed = JSON.parse(msg.data) as IProtocolMessage; + parsed = JSON.parse(msg.data) as ProtocolMessage; } catch (err) { this._malformedFrames++; if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) { @@ -73,7 +73,7 @@ export class TunnelRelayTransport extends Disposable implements IProtocolTranspo super.dispose(); } - send(message: IProtocolMessage | IAhpServerNotification | IJsonRpcResponse): void { + send(message: ProtocolMessage | AhpServerNotification | JsonRpcResponse): void { this._tunnelService.relaySend(this._connectionId, JSON.stringify(message)).catch(() => { // Send failed — connection probably closed }); diff --git a/src/vs/platform/agentHost/node/agentEventMapper.ts b/src/vs/platform/agentHost/node/agentEventMapper.ts index 402457ddd2278..7a48b4f4be61c 100644 --- a/src/vs/platform/agentHost/node/agentEventMapper.ts +++ b/src/vs/platform/agentHost/node/agentEventMapper.ts @@ -19,14 +19,14 @@ import type { } from '../common/agentService.js'; import { ActionType, - type ISessionAction, - type ISessionErrorAction, - type ISessionInputRequestedAction, + type SessionAction, + type SessionErrorAction, + type SessionInputRequestedAction, type ITitleChangedAction, type IToolCallCompleteAction, type IToolCallReadyAction, type IToolCallStartAction, - type ISessionToolCallContentChangedAction, + type SessionToolCallContentChangedAction, type ITurnCompleteAction, type IUsageAction } from '../common/state/sessionActions.js'; @@ -55,12 +55,12 @@ export class AgentEventMapper { /** * Maps a flat {@link IAgentProgressEvent} from the agent host into - * protocol {@link ISessionAction}(s) suitable for dispatch to the reducer. + * protocol {@link SessionAction}(s) suitable for dispatch to the reducer. * * Returns `undefined` for events that have no corresponding action. * May return an array when a single SDK event maps to multiple protocol actions. */ - mapProgressEventToActions(event: IAgentProgressEvent, session: URI, turnId: string): ISessionAction | ISessionAction[] | undefined { + mapProgressEventToActions(event: IAgentProgressEvent, session: URI, turnId: string): SessionAction | SessionAction[] | undefined { switch (event.type) { case 'delta': { const e = event as IAgentDeltaEvent; @@ -175,7 +175,7 @@ export class AgentEventMapper { turnId, toolCallId: e.toolCallId, content: e.content, - } satisfies ISessionToolCallContentChangedAction; + } satisfies SessionToolCallContentChangedAction; } case 'idle': @@ -196,7 +196,7 @@ export class AgentEventMapper { message: e.message, stack: e.stack, }, - } satisfies ISessionErrorAction; + } satisfies SessionErrorAction; } case 'usage': { @@ -275,7 +275,7 @@ export class AgentEventMapper { type: ActionType.SessionInputRequested, session, request: e.request, - } satisfies ISessionInputRequestedAction; + } satisfies SessionInputRequestedAction; } default: diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 255ffb6c92515..c0cbbd9f0c589 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -7,36 +7,36 @@ import { RunOnceScheduler } from '../../../base/common/async.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { ILogService } from '../../log/common/log.js'; -import { ActionType, NotificationType, IActionEnvelope, IActionOrigin, INotification, ISessionAction, IRootAction, IStateAction, isRootAction, isSessionAction, type ITerminalAction } from '../common/state/sessionActions.js'; +import { ActionType, NotificationType, ActionEnvelope, ActionOrigin, INotification, SessionAction, RootAction, StateAction, isRootAction, isSessionAction, type TerminalAction } from '../common/state/sessionActions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer } from '../common/state/sessionReducers.js'; -import { createRootState, createSessionState, SessionLifecycle, type IRootState, type ISessionState, type ISessionSummary, type ITurn, type URI, ROOT_STATE_URI } from '../common/state/sessionState.js'; +import { createRootState, createSessionState, SessionLifecycle, type RootState, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI } from '../common/state/sessionState.js'; /** * Server-side state manager for the sessions process protocol. * * Maintains the authoritative state tree (root + per-session), applies actions * through pure reducers, assigns monotonic sequence numbers, and emits - * {@link IActionEnvelope}s for subscribed clients. + * {@link ActionEnvelope}s for subscribed clients. */ export class AgentHostStateManager extends Disposable { private _serverSeq = 0; - private _rootState: IRootState; - private readonly _sessionStates = new Map(); + private _rootState: RootState; + private readonly _sessionStates = new Map(); /** Tracks which session URI each active turn belongs to, keyed by turnId. */ private readonly _activeTurnToSession = new Map(); /** Last summary sent to clients (via sessionAdded or sessionSummaryChanged). */ - private readonly _lastNotifiedSummaries = new Map(); + private readonly _lastNotifiedSummaries = new Map(); /** Sessions whose summary changed since the last flush. */ private readonly _dirtySummaries = new Set(); private readonly _summaryNotifyScheduler = this._register(new RunOnceScheduler(() => this._flushSummaryNotifications(), 100)); - private readonly _onDidEmitEnvelope = this._register(new Emitter()); - readonly onDidEmitEnvelope: Event = this._onDidEmitEnvelope.event; + private readonly _onDidEmitEnvelope = this._register(new Emitter()); + readonly onDidEmitEnvelope: Event = this._onDidEmitEnvelope.event; private readonly _onDidEmitNotification = this._register(new Emitter()); readonly onDidEmitNotification: Event = this._onDidEmitNotification.event; @@ -55,11 +55,11 @@ export class AgentHostStateManager extends Disposable { // ---- State accessors ---------------------------------------------------- - get rootState(): IRootState { + get rootState(): RootState { return this._rootState; } - getSessionState(session: URI): ISessionState | undefined { + getSessionState(session: URI): SessionState | undefined { return this._sessionStates.get(session); } @@ -115,7 +115,7 @@ export class AgentHostStateManager extends Disposable { * Creates a new session in state with `lifecycle: 'creating'`. * Returns the initial session state. */ - createSession(summary: ISessionSummary): ISessionState { + createSession(summary: SessionSummary): SessionState { const key = summary.resource; if (this._sessionStates.has(key)) { this._logService.warn(`[AgentHostStateManager] Session already exists: ${key}`); @@ -145,14 +145,14 @@ export class AgentHostStateManager extends Disposable { * notification because the session is already known to clients via * `listSessions`. */ - restoreSession(summary: ISessionSummary, turns: ITurn[]): ISessionState { + restoreSession(summary: SessionSummary, turns: Turn[]): SessionState { const key = summary.resource; if (this._sessionStates.has(key)) { this._logService.warn(`[AgentHostStateManager] Session already exists (restore): ${key}`); return this._sessionStates.get(key)!; } - const state: ISessionState = { + const state: SessionState = { ...createSessionState(summary), lifecycle: SessionLifecycle.Ready, turns, @@ -219,7 +219,7 @@ export class AgentHostStateManager extends Disposable { * The action is applied to state via the reducer and emitted as an * envelope with no origin (server-produced). */ - dispatchServerAction(action: IStateAction): void { + dispatchServerAction(action: StateAction): void { this._applyAndEmit(action, undefined); } @@ -228,22 +228,22 @@ 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: ISessionAction | ITerminalAction, origin: IActionOrigin): unknown { + dispatchClientAction(action: SessionAction | TerminalAction, origin: ActionOrigin): unknown { return this._applyAndEmit(action, origin); } // ---- Internal ----------------------------------------------------------- - private _applyAndEmit(action: IStateAction, origin: IActionOrigin | undefined): unknown { + private _applyAndEmit(action: StateAction, origin: ActionOrigin | undefined): unknown { let resultingState: unknown = undefined; // Apply to state if (isRootAction(action)) { - this._rootState = rootReducer(this._rootState, action as IRootAction, this._log); + this._rootState = rootReducer(this._rootState, action as RootAction, this._log); resultingState = this._rootState; } if (isSessionAction(action)) { - const sessionAction = action as ISessionAction; + const sessionAction = action as SessionAction; const key = sessionAction.session; const state = this._sessionStates.get(key); if (state) { @@ -276,7 +276,7 @@ export class AgentHostStateManager extends Disposable { } // Emit envelope - const envelope: IActionEnvelope = { + const envelope: ActionEnvelope = { action, serverSeq: ++this._serverSeq, origin, @@ -297,7 +297,7 @@ export class AgentHostStateManager extends Disposable { } const current = state.summary; - const changes: Partial = {}; + const changes: Partial = {}; if (current.title !== lastNotified.title) { changes.title = current.title; } if (current.status !== lastNotified.status) { changes.status = current.status; } if (current.modifiedAt !== lastNotified.modifiedAt) { changes.modifiedAt = current.modifiedAt; } diff --git a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts index f0a77734e4029..a88f738cd64ca 100644 --- a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts +++ b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts @@ -16,8 +16,8 @@ import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; import { getShellIntegrationInjection } from '../../terminal/node/terminalEnvironment.js'; import { ActionType } from '../common/state/protocol/actions.js'; -import type { ICreateTerminalParams } from '../common/state/protocol/commands.js'; -import { ITerminalClaim, ITerminalContentPart, ITerminalInfo, ITerminalState, TerminalClaimKind } from '../common/state/protocol/state.js'; +import type { CreateTerminalParams } from '../common/state/protocol/commands.js'; +import { TerminalClaim, TerminalContentPart, TerminalInfo, TerminalState, TerminalClaimKind } from '../common/state/protocol/state.js'; import { isTerminalAction } from '../common/state/sessionActions.js'; import type { AgentHostStateManager } from './agentHostStateManager.js'; import { Osc633Event, Osc633EventType, Osc633Parser } from './osc633Parser.js'; @@ -38,20 +38,20 @@ export interface ICommandFinishedEvent { */ export interface IAgentHostTerminalManager { readonly _serviceBrand: undefined; - createTerminal(params: ICreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean }): Promise; + createTerminal(params: CreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean }): Promise; writeInput(uri: string, data: string): void; onData(uri: string, cb: (data: string) => void): IDisposable; onExit(uri: string, cb: (exitCode: number) => void): IDisposable; - onClaimChanged(uri: string, cb: (claim: ITerminalClaim) => void): IDisposable; + onClaimChanged(uri: string, cb: (claim: TerminalClaim) => void): IDisposable; onCommandFinished(uri: string, cb: (event: ICommandFinishedEvent) => void): IDisposable; getContent(uri: string): string | undefined; - getClaim(uri: string): ITerminalClaim | undefined; + getClaim(uri: string): TerminalClaim | undefined; hasTerminal(uri: string): boolean; getExitCode(uri: string): number | undefined; supportsCommandDetection(uri: string): boolean; disposeTerminal(uri: string): void; - getTerminalInfos(): ITerminalInfo[]; - getTerminalState(uri: string): ITerminalState | undefined; + getTerminalInfos(): TerminalInfo[]; + getTerminalState(uri: string): TerminalState | undefined; } // node-pty is loaded dynamically to avoid bundling issues in non-node environments @@ -81,15 +81,15 @@ interface IManagedTerminal { readonly pty: import('node-pty').IPty; readonly onDataEmitter: Emitter; readonly onExitEmitter: Emitter; - readonly onClaimChangedEmitter: Emitter; + readonly onClaimChangedEmitter: Emitter; readonly onCommandFinishedEmitter: Emitter; title: string; cwd: string; cols: number; rows: number; - content: ITerminalContentPart[]; + content: TerminalContentPart[]; contentSize: number; - claim: ITerminalClaim; + claim: TerminalClaim; exitCode?: number; commandTracker?: ICommandTracker; } @@ -141,7 +141,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe } /** Get metadata for all active terminals (for root state). */ - getTerminalInfos(): ITerminalInfo[] { + getTerminalInfos(): TerminalInfo[] { return [...this._terminals.values()].map(t => ({ resource: t.uri, title: t.title, @@ -151,7 +151,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe } /** Get the full state for a terminal (for subscribe snapshots). */ - getTerminalState(uri: string): ITerminalState | undefined { + getTerminalState(uri: string): TerminalState | undefined { const terminal = this._terminals.get(uri); if (!terminal) { return undefined; @@ -172,7 +172,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe * Create a new terminal backed by node-pty. * Spawns the user's default shell. */ - async createTerminal(params: ICreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean }): Promise { + async createTerminal(params: CreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean }): Promise { const uri = params.terminal; if (this._terminals.has(uri)) { throw new Error(`Terminal already exists: ${uri}`); @@ -270,11 +270,11 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe }); const store = new DisposableStore(); - const claim: ITerminalClaim = params.claim ?? { kind: TerminalClaimKind.Client, clientId: '' }; + const claim: TerminalClaim = params.claim ?? { kind: TerminalClaimKind.Client, clientId: '' }; const onDataEmitter = store.add(new Emitter()); const onExitEmitter = store.add(new Emitter()); - const onClaimChangedEmitter = store.add(new Emitter()); + const onClaimChangedEmitter = store.add(new Emitter()); const onCommandFinishedEmitter = store.add(new Emitter()); const managed: IManagedTerminal = { @@ -376,7 +376,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe } /** Register a callback for terminal claim changes. */ - onClaimChanged(uri: string, cb: (claim: ITerminalClaim) => void): IDisposable { + onClaimChanged(uri: string, cb: (claim: TerminalClaim) => void): IDisposable { const terminal = this._terminals.get(uri); if (!terminal) { return toDisposable(() => { }); @@ -403,7 +403,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe } /** Get the current claim for a terminal. */ - getClaim(uri: string): ITerminalClaim | undefined { + getClaim(uri: string): TerminalClaim | undefined { return this._terminals.get(uri)?.claim; } @@ -434,7 +434,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe } /** Update a terminal's claim. */ - private _setClaim(uri: string, claim: ITerminalClaim): void { + private _setClaim(uri: string, claim: TerminalClaim): void { const terminal = this._terminals.get(uri); if (terminal) { terminal.claim = claim; @@ -620,7 +620,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe } } - private _getContentPartSize(part: ITerminalContentPart): number { + private _getContentPartSize(part: TerminalContentPart): number { return part.type === 'command' ? part.output.length : part.value.length; } @@ -667,7 +667,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe } /** - * Resolves the cwd string from {@link ICreateTerminalParams} to an + * Resolves the cwd string from {@link CreateTerminalParams} to an * accessible filesystem path, falling back to $HOME if the requested * directory is missing (otherwise node-pty exits silently with code 1). * Accepts either a `file://` URI string or a raw absolute filesystem path. diff --git a/src/vs/platform/agentHost/node/agentPluginManager.ts b/src/vs/platform/agentHost/node/agentPluginManager.ts index 2ba70f32a06a4..07ad50cdef616 100644 --- a/src/vs/platform/agentHost/node/agentPluginManager.ts +++ b/src/vs/platform/agentHost/node/agentPluginManager.ts @@ -9,7 +9,7 @@ import { URI } from '../../../base/common/uri.js'; import { IFileService } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; import { IAgentPluginManager, type ISyncedCustomization } from '../common/agentPluginManager.js'; -import { CustomizationStatus, type ICustomizationRef, type ISessionCustomization } from '../common/state/sessionState.js'; +import { CustomizationStatus, type CustomizationRef, type SessionCustomization } from '../common/state/sessionState.js'; import { toAgentClientUri } from '../common/agentClientUri.js'; const DEFAULT_MAX_PLUGINS = 20; @@ -63,13 +63,13 @@ export class AgentPluginManager implements IAgentPluginManager { async syncCustomizations( clientId: string, - customizations: ICustomizationRef[], - progress?: (status: ISessionCustomization[]) => void, + customizations: CustomizationRef[], + progress?: (status: SessionCustomization[]) => void, ): Promise { await this._ensureCacheLoaded(); // Build initial loading status and fire it immediately via progress - const statuses: ISessionCustomization[] = customizations.map(c => ({ + const statuses: SessionCustomization[] = customizations.map(c => ({ customization: c, enabled: true, status: CustomizationStatus.Loading, @@ -103,7 +103,7 @@ export class AgentPluginManager implements IAgentPluginManager { * Syncs a single plugin to local storage. Skips the copy when the * nonce matches the cached value. Returns the local directory URI. */ - private async _syncPlugin(clientId: string, ref: ICustomizationRef): Promise { + private async _syncPlugin(clientId: string, ref: CustomizationRef): Promise { const pluginUri = toAgentClientUri(URI.parse(ref.uri), clientId); const key = this._keyForUri(ref.uri); const destDir = URI.joinPath(this._basePath, key); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 8e65b14a0c7ba..20cb762a31de9 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -12,12 +12,12 @@ import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCode } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentMessageEvent, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent, IAuthenticateParams, IAuthenticateResult } from '../common/agentService.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, IActionEnvelope, INotification, ISessionAction, ITerminalAction, isSessionAction } from '../common/state/sessionActions.js'; -import type { ICreateTerminalParams, IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; -import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IDirectoryEntry, type IResourceCopyParams, type IResourceCopyResult, type IResourceDeleteParams, type IResourceDeleteResult, type IResourceListResult, type IResourceMoveParams, type IResourceMoveResult, type IResourceReadResult, type IResourceWriteParams, type IResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; -import { ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, parseSubagentSessionUri, type IResponsePart, type ISessionConfigState, type ISessionFileDiff, type ISessionSummary, type IToolCallCompletedState, type IToolResultSubagentContent, type ITurn } from '../common/state/sessionState.js'; +import { ActionType, ActionEnvelope, INotification, 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'; import { IProductService } from '../../product/common/productService.js'; import { AgentSideEffects } from './agentSideEffects.js'; import { AgentHostTerminalManager, type IAgentHostTerminalManager } from './agentHostTerminalManager.js'; @@ -49,7 +49,7 @@ export class AgentService extends Disposable implements IAgentService { declare readonly _serviceBrand: undefined; /** Protocol: fires when state is mutated by an action. */ - private readonly _onDidAction = this._register(new Emitter()); + private readonly _onDidAction = this._register(new Emitter()); readonly onDidAction = this._onDidAction.event; /** Protocol: fires for ephemeral notifications (sessionAdded/Removed). */ @@ -121,7 +121,7 @@ export class AgentService extends Disposable implements IAgentService { // ---- auth --------------------------------------------------------------- - async authenticate(params: IAuthenticateParams): Promise { + async authenticate(params: AuthenticateParams): Promise { this._logService.trace(`[AgentService] authenticate called: resource=${params.resource}`); for (const provider of this._providers.values()) { const resources = provider.getProtectedResources(); @@ -243,13 +243,13 @@ export class AgentService extends Disposable implements IAgentService { // the source session's turns so the client sees the forked history. if (config?.fork) { const sourceState = this._stateManager.getSessionState(config.fork.session.toString()); - let sourceTurns: ITurn[] = []; + let sourceTurns: Turn[] = []; if (sourceState && config.fork.turnIdMapping) { sourceTurns = sourceState.turns.slice(0, config.fork.turnIndex + 1) .map(t => ({ ...t, id: config!.fork!.turnIdMapping!.get(t.id) ?? generateUuid() })); } - const summary: ISessionSummary = { + const summary: SessionSummary = { resource: session.toString(), provider: provider.id, title: sourceState?.summary.title ?? 'Forked Session', @@ -266,7 +266,7 @@ export class AgentService extends Disposable implements IAgentService { state.activeClient = config.activeClient; } else { // Create empty state for new sessions - const summary: ISessionSummary = { + const summary: SessionSummary = { resource: session.toString(), provider: provider.id, title: '', @@ -294,7 +294,7 @@ export class AgentService extends Disposable implements IAgentService { return session; } - private _persistConfigValues(session: URI, values: Record): void { + private _persistConfigValues(session: URI, values: Record): void { let ref; try { ref = this._sessionDataService.openDatabase(session); @@ -309,7 +309,7 @@ export class AgentService extends Disposable implements IAgentService { }); } - private async _resolveCreatedSessionConfig(provider: IAgent, config: IAgentCreateSessionConfig | undefined): Promise { + private async _resolveCreatedSessionConfig(provider: IAgent, config: IAgentCreateSessionConfig | undefined): Promise { if (!config?.config && !config?.workingDirectory) { return undefined; } @@ -326,7 +326,7 @@ export class AgentService extends Disposable implements IAgentService { } } - async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { + async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { const providerId = params.provider ?? this._defaultProvider; const provider = providerId ? this._providers.get(providerId) : undefined; if (!provider) { @@ -335,7 +335,7 @@ export class AgentService extends Disposable implements IAgentService { return provider.resolveSessionConfig(params); } - async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { + async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { const providerId = params.provider ?? this._defaultProvider; const provider = providerId ? this._providers.get(providerId) : undefined; if (!provider) { @@ -358,7 +358,7 @@ export class AgentService extends Disposable implements IAgentService { // ---- Protocol methods --------------------------------------------------- - async createTerminal(params: ICreateTerminalParams): Promise { + async createTerminal(params: CreateTerminalParams): Promise { await this._terminalManager.createTerminal(params); } @@ -399,7 +399,7 @@ export class AgentService extends Disposable implements IAgentService { // in Phase 4 (multi-client). For now this is a no-op. } - dispatchAction(action: ISessionAction | ITerminalAction, clientId: string, clientSeq: number): void { + dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void { this._logService.trace(`[AgentService] dispatchAction: type=${action.type}, clientId=${clientId}, clientSeq=${clientSeq}`, action); const origin = { clientId, clientSeq }; @@ -412,7 +412,7 @@ export class AgentService extends Disposable implements IAgentService { } } - async resourceList(uri: URI): Promise { + async resourceList(uri: URI): Promise { let stat; try { stat = await this._fileService.resolve(uri); @@ -424,7 +424,7 @@ export class AgentService extends Disposable implements IAgentService { throw new ProtocolError(AhpErrorCodes.NotFound, `Not a directory: ${uri.toString()}`); } - const entries: IDirectoryEntry[] = (stat.children ?? []).map(child => ({ + const entries: DirectoryEntry[] = (stat.children ?? []).map(child => ({ name: child.name, type: child.isDirectory ? 'directory' : 'file', })); @@ -514,7 +514,7 @@ export class AgentService extends Disposable implements IAgentService { } } - const summary: ISessionSummary = { + const summary: SessionSummary = { resource: sessionStr, provider: agent.id, title, @@ -550,7 +550,7 @@ export class AgentService extends Disposable implements IAgentService { this._logService.info(`[AgentService] Restored session ${sessionStr} with ${turns.length} turns`); } - async resourceRead(uri: URI): Promise { + async resourceRead(uri: URI): Promise { // Handle session-db: URIs that reference file-edit content stored // in a per-session SQLite database. const dbFields = parseSessionDbUri(uri.toString()); @@ -570,7 +570,7 @@ export class AgentService extends Disposable implements IAgentService { } } - async resourceWrite(params: IResourceWriteParams): Promise { + async resourceWrite(params: ResourceWriteParams): Promise { const fileUri = typeof params.uri === 'string' ? URI.parse(params.uri) : URI.revive(params.uri); let content: VSBuffer; if (params.encoding === ContentEncoding.Base64) { @@ -597,7 +597,7 @@ export class AgentService extends Disposable implements IAgentService { } } - async resourceCopy(params: IResourceCopyParams): Promise { + async resourceCopy(params: ResourceCopyParams): Promise { const source = URI.parse(params.source); const destination = URI.parse(params.destination); try { @@ -615,7 +615,7 @@ export class AgentService extends Disposable implements IAgentService { } } - async resourceDelete(params: IResourceDeleteParams): Promise { + async resourceDelete(params: ResourceDeleteParams): Promise { const fileUri = URI.parse(params.uri); try { await this._fileService.del(fileUri, { recursive: params.recursive }); @@ -629,7 +629,7 @@ export class AgentService extends Disposable implements IAgentService { } } - async resourceMove(params: IResourceMoveParams): Promise { + async resourceMove(params: ResourceMoveParams): Promise { const source = URI.parse(params.source); const destination = URI.parse(params.destination); try { @@ -660,21 +660,21 @@ export class AgentService extends Disposable implements IAgentService { // ---- helpers ------------------------------------------------------------ /** - * Reconstructs completed `ITurn[]` from a sequence of agent session + * Reconstructs completed `Turn[]` from a sequence of agent session * messages. Each user-message starts a new turn; the assistant message * closes it. */ private _buildTurnsFromMessages( messages: readonly (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent | IAgentSubagentStartedEvent)[], - ): ITurn[] { - const turns: ITurn[] = []; + ): Turn[] { + const turns: Turn[] = []; // Track subagent metadata by parent tool call ID so we can inject - // IToolResultSubagentContent into the parent tool call's completion content + // ToolResultSubagentContent into the parent tool call's completion content const subagentsByToolCallId = new Map(); let currentTurn: { id: string; userMessage: { text: string }; - responseParts: IResponsePart[]; + responseParts: ResponsePart[]; pendingTools: Map; } | undefined; @@ -756,7 +756,7 @@ export class AgentService extends Disposable implements IAgentService { }); } - const tc: IToolCallCompletedState = { + const tc: ToolCallCompletedState = { status: ToolCallStatus.Completed, toolCallId: msg.toolCallId, toolName: start?.toolName ?? 'unknown', @@ -799,7 +799,7 @@ export class AgentService extends Disposable implements IAgentService { parentMessages: readonly (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent | IAgentSubagentStartedEvent)[], parentToolCallId: string, childSessionUri: string, - ): ITurn[] { + ): Turn[] { // Collect all inner tool call IDs that belong to this subagent const innerToolCallIds = new Set(); for (const msg of parentMessages) { @@ -833,7 +833,7 @@ export class AgentService extends Disposable implements IAgentService { } // Build a single turn with all inner tool calls - const responseParts: IResponsePart[] = []; + const responseParts: ResponsePart[] = []; const pendingTools = new Map(); for (const msg of innerMessages) { @@ -856,7 +856,7 @@ export class AgentService extends Disposable implements IAgentService { }); } - const tc: IToolCallCompletedState = { + const tc: ToolCallCompletedState = { status: ToolCallStatus.Completed, toolCallId: msg.toolCallId, toolName: start?.toolName ?? 'unknown', @@ -900,7 +900,7 @@ export class AgentService extends Disposable implements IAgentService { }]; } - private async _fetchSessionDbContent(fields: ISessionDbUriFields): Promise { + private async _fetchSessionDbContent(fields: ISessionDbUriFields): Promise { const sessionUri = URI.parse(fields.sessionUri); const ref = this._sessionDataService.openDatabase(sessionUri); try { @@ -948,10 +948,10 @@ export class AgentService extends Disposable implements IAgentService { // Search completed turns and active turn for the subagent content metadata const allTurns = [...parentState.turns]; if (parentState.activeTurn) { - allTurns.push(parentState.activeTurn as ITurn); + allTurns.push(parentState.activeTurn as Turn); } - let subagentContent: IToolResultSubagentContent | undefined; + let subagentContent: ToolResultSubagentContent | undefined; for (const turn of allTurns) { for (const part of turn.responseParts) { if (part.kind === ResponsePartKind.ToolCall) { @@ -977,7 +977,7 @@ export class AgentService extends Disposable implements IAgentService { } // Load parent's raw messages and extract inner events for this subagent - let childTurns: ITurn[] = []; + let childTurns: Turn[] = []; const agent = this._findProviderForSession(parentUri); if (agent) { try { diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 82c574413c189..c13039b8bdcb2 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -4,11 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { disposableTimeout, SequencerByKey } from '../../../base/common/async.js'; -import { match as globMatch } from '../../../base/common/glob.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { equals } from '../../../base/common/objects.js'; import { autorun, IObservable, IReader } from '../../../base/common/observable.js'; -import { extUriBiasedIgnorePathCase, normalizePath } from '../../../base/common/resources.js'; import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; @@ -16,8 +14,8 @@ import { ILogService } from '../../log/common/log.js'; import { IAgent, IAgentAttachment, IAgentProgressEvent, type IAgentToolCompleteEvent, type IAgentToolReadyEvent } from '../common/agentService.js'; import { IDiffComputeService } from '../common/diffComputeService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; -import type { IAgentInfo } from '../common/state/protocol/state.js'; -import { ActionType, ISessionAction } from '../common/state/sessionActions.js'; +import type { AgentInfo } from '../common/state/protocol/state.js'; +import { ActionType, SessionAction } from '../common/state/sessionActions.js'; import { CustomizationStatus, PendingMessageKind, @@ -27,17 +25,16 @@ import { ToolResultContentType, buildSubagentSessionUri, getToolFileEdits, - parseSubagentSessionUri, - type ISessionCustomization, - type ISessionState, - type IToolResultContent, + type SessionCustomization, + type SessionState, + type ToolResultContent, type URI as ProtocolURI, } from '../common/state/sessionState.js'; import { AgentEventMapper } from './agentEventMapper.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; -import { CommandAutoApprover } from './commandAutoApprover.js'; import { NodeWorkerDiffComputeService } from './diffComputeService.js'; import { computeSessionDiffs, type IIncrementalDiffOptions } from './sessionDiffAggregator.js'; +import { SessionPermissionManager } from './sessionPermissions.js'; /** * Options for constructing an {@link AgentSideEffects} instance. @@ -74,17 +71,17 @@ export class AgentSideEffects extends Disposable { private readonly _toolCallAgents = new Map(); /** Per-agent event mapper instances (stateful for partId tracking). */ private readonly _eventMappers = new Map(); - /** Auto-approver for shell commands parsed via tree-sitter. */ - private readonly _commandAutoApprover: CommandAutoApprover; /** Shared diff compute service for calculating line-level diffs in a worker thread. */ private readonly _diffComputeService: IDiffComputeService; /** Serializes per-session diff computations to avoid races with stale previousDiffs. */ private readonly _diffComputationSequencer = new SequencerByKey(); - private _lastAgentInfos: readonly IAgentInfo[] = []; + private _lastAgentInfos: readonly AgentInfo[] = []; /** Per-session debounce timers for mid-turn diff computation. */ private readonly _debouncedDiffTimers = this._register(new DisposableMap()); private static readonly _DIFF_DEBOUNCE_MS = 5000; + private readonly _permissionManager: SessionPermissionManager; + /** * Maps `parentSession:toolCallId` → subagent session URI. * Used to route events with `parentToolCallId` to the correct subagent. @@ -110,8 +107,8 @@ export class AgentSideEffects extends Disposable { private readonly _logService: ILogService, ) { super(); - this._commandAutoApprover = this._register(new CommandAutoApprover(this._logService)); this._diffComputeService = this._register(new NodeWorkerDiffComputeService(this._logService)); + this._permissionManager = this._register(new SessionPermissionManager(this._stateManager, this._logService)); // Whenever the agents observable changes, publish to root state. this._register(autorun(reader => { @@ -124,7 +121,7 @@ export class AgentSideEffects extends Disposable { * Publishes agent descriptors using the last known model lists. */ private _publishAgentInfos(agents: readonly IAgent[], reader: IReader): void { - const infos: IAgentInfo[] = agents.map(a => { + const infos: AgentInfo[] = agents.map(a => { const d = a.getDescriptor(); const protectedResources = a.getProtectedResources(); return { @@ -147,120 +144,15 @@ export class AgentSideEffects extends Disposable { this._stateManager.dispatchServerAction({ type: ActionType.RootAgentsChanged, agents: infos }); } - // ---- Edit auto-approve -------------------------------------------------- - - /** - * Default edit auto-approve patterns applied by the agent host. - * Matches the VS Code `chat.tools.edits.autoApprove` setting defaults. - */ - private static readonly _DEFAULT_EDIT_AUTO_APPROVE_PATTERNS: Readonly> = { - '**/*': true, - '**/.vscode/*.json': false, - '**/.git/**': false, - '**/{package.json,server.xml,build.rs,web.config,.gitattributes,.env}': false, - '**/*.{code-workspace,csproj,fsproj,vbproj,vcxproj,proj,targets,props}': false, - '**/*.lock': false, - '**/*-lock.{yaml,json}': false, - }; - - /** - * Returns whether a write to `filePath` should be auto-approved based on - * the built-in default patterns. - */ - private _shouldAutoApproveEdit(filePath: string): boolean { - const patterns = AgentSideEffects._DEFAULT_EDIT_AUTO_APPROVE_PATTERNS; - let approved = true; - for (const [pattern, isApproved] of Object.entries(patterns)) { - if (isApproved !== approved && globMatch(pattern, filePath)) { - approved = isApproved; - } - } - return approved; - } + // ---- Initialization ---------------------------------------------------- /** * Initializes async resources (tree-sitter WASM) used for command * auto-approval. Await this before any session events can arrive to - * guarantee that {@link _tryAutoApproveToolReady} is fully synchronous. + * guarantee that auto-approval checks are fully synchronous. */ initialize(): Promise { - return this._commandAutoApprover.initialize(); - } - - /** - * Synchronously attempts to auto-approve a `tool_ready` event based on - * permission kind. Returns `true` if auto-approved (event should not be - * dispatched to the state manager), or `false` to proceed normally. - */ - private _tryAutoApproveToolReady( - e: { readonly toolCallId: string; readonly session: URI; readonly permissionKind?: IAgentToolReadyEvent['permissionKind']; readonly permissionPath?: string; readonly toolInput?: string }, - sessionKey: ProtocolURI, - agent: IAgent, - ): boolean { - // Subagent sessions don't carry their own config or workingDirectory — - // inherit from the parent session so auto-approval rules apply - // uniformly to tool calls inside subagents. - const sessionState = this._stateManager.getSessionState(sessionKey); - const parentInfo = parseSubagentSessionUri(sessionKey); - const parentState = parentInfo ? this._stateManager.getSessionState(parentInfo.parentSession) : undefined; - const autoApproveLevel = sessionState?.config?.values?.autoApprove - ?? parentState?.config?.values?.autoApprove; - const workDir = sessionState?.summary.workingDirectory - ?? parentState?.summary.workingDirectory; - - // Session-level auto-approve: when the user has set "Bypass Approvals" - // or "Autopilot", auto-approve all tool calls unconditionally. - if (autoApproveLevel === 'autoApprove' || autoApproveLevel === 'autopilot') { - this._logService.trace(`[AgentSideEffects] Auto-approving tool call (session autoApprove=${autoApproveLevel})`); - this._toolCallAgents.delete(`${sessionKey}:${e.toolCallId}`); - agent.respondToPermissionRequest(e.toolCallId, true); - return true; - } - - // Read auto-approval: approve reads inside the session's working directory. - if (e.permissionKind === 'read' && e.permissionPath) { - const workingDirectory = workDir ? URI.parse(workDir) : undefined; - if (workingDirectory && extUriBiasedIgnorePathCase.isEqualOrParent(normalizePath(URI.file(e.permissionPath)), workingDirectory)) { - this._logService.trace(`[AgentSideEffects] Auto-approving read of ${e.permissionPath}`); - this._toolCallAgents.delete(`${sessionKey}:${e.toolCallId}`); - agent.respondToPermissionRequest(e.toolCallId, true); - return true; - } - return false; - } - - // Write auto-approval: only within the session's working directory, - // then apply the default glob patterns for protected files. - if (e.permissionKind === 'write' && e.permissionPath) { - const workingDirectory = workDir ? URI.parse(workDir) : undefined; - if (workingDirectory && extUriBiasedIgnorePathCase.isEqualOrParent(normalizePath(URI.file(e.permissionPath)), workingDirectory)) { - if (this._shouldAutoApproveEdit(e.permissionPath)) { - this._logService.trace(`[AgentSideEffects] Auto-approving write to ${e.permissionPath}`); - this._toolCallAgents.delete(`${sessionKey}:${e.toolCallId}`); - agent.respondToPermissionRequest(e.toolCallId, true); - return true; - } - } - return false; - } - - // Shell auto-approval: parse the command via tree-sitter (synchronous - // after initialize() has been awaited) and match against default rules. - if (e.permissionKind === 'shell' && e.toolInput) { - const result = this._commandAutoApprover.shouldAutoApprove(e.toolInput); - if (result === 'approved') { - this._logService.trace(`[AgentSideEffects] Auto-approving shell command`); - this._toolCallAgents.delete(`${sessionKey}:${e.toolCallId}`); - agent.respondToPermissionRequest(e.toolCallId, true); - return true; - } - if (result === 'denied') { - this._logService.trace(`[AgentSideEffects] Shell command denied by rule`); - } - return false; - } - - return false; + return this._permissionManager.initialize(); } // ---- Agent registration ------------------------------------------------- @@ -328,11 +220,10 @@ export class AgentSideEffects extends Disposable { const subTurnId = this._stateManager.getActiveTurnId(subagentSession); if (subTurnId) { if (e.type === 'tool_ready') { - if (this._tryAutoApproveToolReady(e, subagentSession, agent)) { - return; - } + this._handleToolReady(e, subagentSession, subTurnId, agent); + } else { + this._dispatchProgressActions(agentMapper, e, subagentSession, subTurnId); } - this._dispatchProgressActions(agentMapper, e, subagentSession, subTurnId); } return; } @@ -359,10 +250,7 @@ export class AgentSideEffects extends Disposable { if (subagentSession) { const subTurnId = this._stateManager.getActiveTurnId(subagentSession); if (subTurnId) { - if (this._tryAutoApproveToolReady(e, subagentSession, agent)) { - return; - } - this._dispatchProgressActions(agentMapper, e, subagentSession, subTurnId); + this._handleToolReady(e, subagentSession, subTurnId, agent); } return; } @@ -370,18 +258,15 @@ export class AgentSideEffects extends Disposable { const turnId = this._stateManager.getActiveTurnId(sessionKey); if (turnId) { - // Auto-approve tool_ready events synchronously before dispatching. - // Tree-sitter is pre-warmed via initialize(), so this is fully sync. if (e.type === 'tool_ready') { - if (this._tryAutoApproveToolReady(e, sessionKey, agent)) { - return; - } + this._handleToolReady(e, sessionKey, turnId, agent); + return; } // When a parent tool call has an associated subagent session, // preserve the subagent content metadata in the completion // result. The SDK's tool_complete provides its own content - // which would overwrite the IToolResultSubagentContent that + // which would overwrite the ToolResultSubagentContent that // was set via SessionToolCallContentChanged while running. if (e.type === 'tool_complete') { const subagentKey = `${sessionKey}:${e.toolCallId}`; @@ -525,10 +410,10 @@ export class AgentSideEffects extends Disposable { * Gets the current content array from a running tool call, if any. */ private _getRunningToolCallContent( - state: ISessionState | undefined, + state: SessionState | undefined, turnId: string, toolCallId: string, - ): IToolResultContent[] { + ): ToolResultContent[] { if (!state?.activeTurn || state.activeTurn.id !== turnId) { return []; } @@ -671,7 +556,24 @@ export class AgentSideEffects extends Disposable { } } - handleAction(action: ISessionAction): void { + /** + * Handles a `tool_ready` event end-to-end: checks for auto-approval via + * the permission manager, and if not auto-approved, dispatches the + * `SessionToolCallReady` action with confirmation options for the client. + */ + private _handleToolReady(e: IAgentToolReadyEvent, sessionKey: ProtocolURI, turnId: string, agent: IAgent): void { + const autoApproval = this._permissionManager.getAutoApproval(e, sessionKey); + if (autoApproval !== undefined) { + this._toolCallAgents.delete(`${sessionKey}:${e.toolCallId}`); + agent.respondToPermissionRequest(e.toolCallId, true); + return; + } + this._stateManager.dispatchServerAction( + this._permissionManager.createToolReadyAction(e, sessionKey, turnId) + ); + } + + handleAction(action: SessionAction): void { switch (action.type) { case ActionType.SessionTurnStarted: { // Reset the event mapper's part tracking for the new turn @@ -730,6 +632,12 @@ export class AgentSideEffects extends Disposable { } else { this._logService.warn(`[AgentSideEffects] No agent for tool call confirmation: ${action.toolCallId}`); } + + // When the user chose "Allow in this Session", add the tool + // to the session's permissions so future calls are auto-approved. + if (action.approved) { + this._permissionManager.handleToolCallConfirmed(action.session, action.toolCallId, action.selectedOptionId); + } break; } case ActionType.SessionInputCompleted: { @@ -786,7 +694,7 @@ export class AgentSideEffects extends Disposable { break; } // Publish initial "loading" status for all customizations - const loading: ISessionCustomization[] = refs.map(r => ({ + const loading: SessionCustomization[] = refs.map(r => ({ customization: r, enabled: true, status: CustomizationStatus.Loading, @@ -801,7 +709,7 @@ export class AgentSideEffects extends Disposable { refs, (synced) => { // Incremental progress: publish updated statuses - const statuses: ISessionCustomization[] = synced.map(s => s.customization); + const statuses: SessionCustomization[] = synced.map(s => s.customization); this._stateManager.dispatchServerAction({ type: ActionType.SessionCustomizationsChanged, session: action.session, @@ -810,7 +718,7 @@ export class AgentSideEffects extends Disposable { }, ).then(synced => { // Final status - const statuses: ISessionCustomization[] = synced.map(s => s.customization); + const statuses: SessionCustomization[] = synced.map(s => s.customization); this._stateManager.dispatchServerAction({ type: ActionType.SessionCustomizationsChanged, session: action.session, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index bde08bb9ade6c..ae2f11f7527aa 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -25,12 +25,13 @@ import { ILogService } from '../../../log/common/log.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { AgentHostSessionConfigBranchNameHintKey, AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentDeltaEvent, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js'; -import type { IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; -import { IProtectedResourceMetadata, type IConfigSchema, type IModelSelection, type IToolDefinition } from '../../common/state/protocol/state.js'; +import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; +import { ProtectedResourceMetadata, type ConfigSchema, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; -import { CustomizationStatus, ICustomizationRef, SessionInputResponseKind, type IPendingMessage, type ISessionInputAnswer, type IToolCallResult, type PolicyState } from '../../common/state/sessionState.js'; +import { CustomizationStatus, CustomizationRef, SessionInputResponseKind, type PendingMessage, type SessionInputAnswer, type ToolCallResult, type PolicyState } from '../../common/state/sessionState.js'; import { IAgentHostGitService } from '../agentHostGitService.js'; import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; +import { SessionPermissionManager } from '../sessionPermissions.js'; import { CopilotAgentSession, SessionWrapperFactory, type IActiveClientSnapshot } from './copilotAgentSession.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; import { parsedPluginsEqual, toSdkCustomAgents, toSdkHooks, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js'; @@ -59,13 +60,34 @@ export interface ICopilotClient { start(): Promise; stop: CopilotClient['stop']; listSessions: CopilotClient['listSessions']; - listModels: CopilotClient['listModels']; + listModels: () => Promise; createSession: CopilotClient['createSession']; resumeSession: CopilotClient['resumeSession']; getSessionMetadata: CopilotClient['getSessionMetadata']; readonly rpc: { readonly sessions: { readonly fork: CopilotClient['rpc']['sessions']['fork'] } }; } +/** + * Corrected shape of {@link CopilotClient.listModels} entries. + * + * The SDK's `ModelInfo` type declares `capabilities`, `capabilities.limits`, + * and `capabilities.limits.max_context_window_tokens` as required, but at + * runtime synthetic entries (e.g. the `auto` router) ship with an empty + * `capabilities: {}` object. We mirror the SDK fields we consume but mark the + * unreliable parts as optional so callers must defensively handle them. + */ +export interface ICopilotModelInfo { + readonly id: string; + readonly name: string; + readonly capabilities?: { + readonly supports?: { readonly vision?: boolean }; + readonly limits?: { readonly max_context_window_tokens?: number }; + }; + readonly policy?: { readonly state?: string }; + readonly supportedReasoningEfforts?: readonly string[]; + readonly defaultReasoningEffort?: string; +} + function isReasoningEffort(value: string | undefined): value is ReasoningEffort { return ReasoningEfforts.some(reasoningEffort => reasoningEffort === value); } @@ -184,7 +206,7 @@ export class CopilotAgent extends Disposable implements IAgent { }; } - getProtectedResources(): IProtectedResourceMetadata[] { + getProtectedResources(): ProtectedResourceMetadata[] { return [{ resource: 'https://api.github.com', resource_name: 'GitHub Copilot', @@ -314,7 +336,7 @@ export class CopilotAgent extends Disposable implements IAgent { // ---- session management ------------------------------------------------- - private _createThinkingLevelConfigSchema(supportedReasoningEfforts: readonly string[] | undefined, defaultReasoningEffort: string | undefined): IConfigSchema | undefined { + private _createThinkingLevelConfigSchema(supportedReasoningEfforts: readonly string[] | undefined, defaultReasoningEffort: string | undefined): ConfigSchema | undefined { if (!supportedReasoningEfforts?.length) { return undefined; } @@ -344,16 +366,16 @@ export class CopilotAgent extends Disposable implements IAgent { }; } - private _getReasoningEffort(model: IModelSelection | undefined): SessionConfig['reasoningEffort'] { + private _getReasoningEffort(model: ModelSelection | undefined): SessionConfig['reasoningEffort'] { const thinkingLevel = model?.config?.[ThinkingLevelConfigKey]; return isReasoningEffort(thinkingLevel) ? thinkingLevel : undefined; } - private _serializeModelSelection(model: IModelSelection): string { + private _serializeModelSelection(model: ModelSelection): string { return JSON.stringify(model); } - private _parseModelSelection(raw: string | undefined): IModelSelection | undefined { + private _parseModelSelection(raw: string | undefined): ModelSelection | undefined { if (!raw) { return undefined; } @@ -361,7 +383,7 @@ export class CopilotAgent extends Disposable implements IAgent { try { const value: ISerializedModelSelection | string | number | boolean | null = JSON.parse(raw); if (value && typeof value === 'object' && typeof value.id === 'string') { - const modelSelection: IModelSelection = { id: value.id }; + const modelSelection: ModelSelection = { id: value.id }; if (value.config && typeof value.config === 'object') { const config: Record = {}; for (const [key, configValue] of Object.entries(value.config)) { @@ -420,12 +442,14 @@ export class CopilotAgent extends Disposable implements IAgent { this._logService.info('[Copilot] Listing models...'); const client = await this._ensureClient(); const models = await client.listModels(); - const result = models.map(m => ({ + const result = models.map((m): IAgentModelInfo => ({ provider: this.id, id: m.id, name: m.name, - maxContextWindow: m.capabilities.limits.max_context_window_tokens, - supportsVision: m.capabilities.supports.vision, + // Synthetic SDK entries like `auto` ship with `capabilities: {}` and + // no fixed context window — surface them with maxContextWindow undefined. + maxContextWindow: m.capabilities?.limits?.max_context_window_tokens, + supportsVision: !!m.capabilities?.supports?.vision, configSchema: this._createThinkingLevelConfigSchema(m.supportedReasoningEfforts, m.defaultReasoningEffort), policyState: m.policy?.state as PolicyState | undefined, })); @@ -548,7 +572,7 @@ export class CopilotAgent extends Disposable implements IAgent { return { session, workingDirectory, ...(project ? { project } : {}) }; } - async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { + async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { const gitInfo = params.workingDirectory ? await this._getGitInfo(params.workingDirectory) : undefined; const isolationValue = params.config?.isolation === 'folder' || params.config?.isolation === 'worktree' ? params.config.isolation @@ -558,7 +582,11 @@ export class CopilotAgent extends Disposable implements IAgent { ? params.config.autoApprove : 'default'; - const values: Record = { isolation: isolationValue, autoApprove: autoApproveValue }; + const values: Record = { + isolation: isolationValue, + autoApprove: autoApproveValue, + [SessionPermissionManager.PERMISSIONS_CONFIG_KEY]: params.config?.[SessionPermissionManager.PERMISSIONS_CONFIG_KEY] || { allow: [], deny: [] }, + }; if (gitInfo) { const branchForMode = isolationValue === 'worktree' ? gitInfo.defaultBranch : gitInfo.currentBranch; values.branch = typeof params.config?.branch === 'string' && isolationValue === 'worktree' @@ -566,7 +594,7 @@ export class CopilotAgent extends Disposable implements IAgent { : branchForMode; } - const properties: IResolveSessionConfigResult['schema']['properties'] = { + const properties: ResolveSessionConfigResult['schema']['properties'] = { isolation: { type: 'string', title: localize('agentHost.sessionConfig.isolation', "Isolation"), @@ -595,6 +623,31 @@ export class CopilotAgent extends Disposable implements IAgent { default: 'default', sessionMutable: true, }, + [SessionPermissionManager.PERMISSIONS_CONFIG_KEY]: { + 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, + }, }; if (gitInfo) { @@ -618,7 +671,7 @@ export class CopilotAgent extends Disposable implements IAgent { }; } - async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { + async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { if (params.property !== 'branch' || !params.workingDirectory) { return { items: [] }; } @@ -627,17 +680,17 @@ export class CopilotAgent extends Disposable implements IAgent { return { items: branches.map(branch => ({ value: branch, label: branch })) }; } - async setClientCustomizations(clientId: string, customizations: ICustomizationRef[], progress?: (results: ISyncedCustomization[]) => void): Promise { + async setClientCustomizations(clientId: string, customizations: CustomizationRef[], progress?: (results: ISyncedCustomization[]) => void): Promise { return this._plugins.sync(clientId, customizations, progress); } - setClientTools(session: URI, clientId: string, tools: IToolDefinition[]): void { + setClientTools(session: URI, clientId: string, tools: ToolDefinition[]): void { const activeClient = this._getOrCreateActiveClient(session); activeClient.updateTools(clientId, tools); this._logService.info(`[Copilot:${AgentSession.id(session)}] Client tools updated: ${tools.map(t => t.name).join(', ') || '(none)'}`); } - onClientToolCallComplete(session: URI, toolCallId: string, result: IToolCallResult): void { + onClientToolCallComplete(session: URI, toolCallId: string, result: ToolCallResult): void { const entry = this._sessions.get(AgentSession.id(session)); entry?.handleClientToolCallComplete(toolCallId, result); } @@ -680,7 +733,7 @@ export class CopilotAgent extends Disposable implements IAgent { }); } - setPendingMessages(session: URI, steeringMessage: IPendingMessage | undefined, _queuedMessages: readonly IPendingMessage[]): void { + setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[]): void { const sessionId = AgentSession.id(session); const entry = this._sessions.get(sessionId); if (!entry) { @@ -772,7 +825,7 @@ export class CopilotAgent extends Disposable implements IAgent { }); } - async changeModel(session: URI, model: IModelSelection): Promise { + async changeModel(session: URI, model: ModelSelection): Promise { const sessionId = AgentSession.id(session); const entry = this._sessions.get(sessionId); if (entry) { @@ -802,7 +855,7 @@ export class CopilotAgent extends Disposable implements IAgent { } } - respondToUserInputRequest(requestId: string, response: SessionInputResponseKind, answers?: Record): void { + respondToUserInputRequest(requestId: string, response: SessionInputResponseKind, answers?: Record): void { for (const [, session] of this._sessions) { if (session.respondToUserInputRequest(requestId, response, answers)) { return; @@ -972,11 +1025,16 @@ export class CopilotAgent extends Disposable implements IAgent { } const worktreesRoot = getCopilotWorktreesRoot(repositoryRoot); - const branchNameHint = config.config[AgentHostSessionConfigBranchNameHintKey]; + const branchNameHintRaw = config.config[AgentHostSessionConfigBranchNameHintKey]; + const branchNameHint = typeof branchNameHintRaw === 'string' ? branchNameHintRaw : undefined; const branchName = getCopilotWorktreeBranchName(sessionId, branchNameHint); const worktree = URI.joinPath(worktreesRoot, getCopilotWorktreeName(branchName)); await fs.mkdir(worktreesRoot.fsPath, { recursive: true }); - await this._gitService.addWorktree(repositoryRoot, worktree, branchName, config.config.branch); + const baseBranch = typeof config.config.branch === 'string' ? config.config.branch : undefined; + // `addWorktree`'s signature requires a startPoint, but historically the + // runtime accepted undefined when `branch` was not set in config. Preserve + // that behavior by passing through whatever value (or undefined) was set. + await this._gitService.addWorktree(repositoryRoot, worktree, branchName, baseBranch as string); this._createdWorktrees.set(sessionId, { repositoryRoot, worktree }); // Queue the worktree announcement so the first turn (live) and any // subsequent restore (history) both surface the message in the chat. @@ -1035,7 +1093,7 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private async _storeSessionMetadata(session: URI, model: IModelSelection | undefined, workingDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined): Promise { + private async _storeSessionMetadata(session: URI, model: ModelSelection | undefined, workingDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined): Promise { const dbRef = this._sessionDataService.openDatabase(session); const db = dbRef.object; try { @@ -1059,7 +1117,7 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private async _readSessionMetadata(session: URI): Promise<{ model?: IModelSelection; workingDirectory?: URI }> { + private async _readSessionMetadata(session: URI): Promise<{ model?: ModelSelection; workingDirectory?: URI }> { const ref = await this._sessionDataService.tryOpenDatabase(session); if (!ref) { return {}; @@ -1078,7 +1136,7 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private async _readStoredSessionMetadata(session: URI): Promise<{ model?: IModelSelection; workingDirectory?: URI; project?: IAgentSessionProjectInfo; resolved: boolean } | undefined> { + private async _readStoredSessionMetadata(session: URI): Promise<{ model?: ModelSelection; workingDirectory?: URI; project?: IAgentSessionProjectInfo; resolved: boolean } | undefined> { const ref = await this._sessionDataService.tryOpenDatabase(session); if (!ref) { return undefined; @@ -1159,7 +1217,7 @@ class PluginController { this._enablement.set(pluginProtocolUri, enabled); } - public sync(clientId: string, customizations: ICustomizationRef[], progress?: (results: ISyncedCustomization[]) => void) { + public sync(clientId: string, customizations: CustomizationRef[], progress?: (results: ISyncedCustomization[]) => void) { const prev = this._lastSynced; const promise = this._lastSynced = prev.catch(err => { this._logService.warn('[Copilot:PluginController] Previous customization sync failed', err); @@ -1202,7 +1260,7 @@ class PluginController { * {@link isOutdated} detects when the session needs to be refreshed. */ class ActiveClient { - private _tools: readonly IToolDefinition[] = []; + private _tools: readonly ToolDefinition[] = []; private _clientId = ''; constructor( @@ -1210,7 +1268,7 @@ class ActiveClient { private readonly _resolvePlugins: () => Promise, ) { } - updateTools(clientId: string, tools: readonly IToolDefinition[]): void { + updateTools(clientId: string, tools: readonly ToolDefinition[]): void { this._clientId = clientId; this._tools = tools; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 3c96497d19151..1f19cbb44bd2e 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -19,8 +19,8 @@ import { IInstantiationService } from '../../../instantiation/common/instantiati import { ILogService } from '../../../log/common/log.js'; import { IAgentAttachment, IAgentMessageEvent, IAgentProgressEvent, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; -import type { IFileEdit, IToolDefinition } from '../../common/state/protocol/state.js'; -import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType, type IPendingMessage, type ISessionInputAnswer, type ISessionInputRequest, type IToolCallResult, type IToolResultContent } from '../../common/state/sessionState.js'; +import type { FileEdit, ToolDefinition } from '../../common/state/protocol/state.js'; +import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType, type PendingMessage, type SessionInputAnswer, type SessionInputRequest, type ToolCallResult, type ToolResultContent } from '../../common/state/sessionState.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import type { ShellManager } from './copilotShellTools.js'; import { getEditFilePath, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; @@ -42,7 +42,7 @@ function getCopilotCLISessionStateDir(userHome: string): string { */ export interface IActiveClientSnapshot { readonly clientId: string; - readonly tools: readonly IToolDefinition[]; + readonly tools: readonly ToolDefinition[]; readonly plugins: readonly IParsedPlugin[]; } @@ -104,11 +104,11 @@ export class CopilotAgentSession extends Disposable { readonly sessionUri: URI; /** Tracks active tool invocations so we can produce past-tense messages on completion. */ - private readonly _activeToolCalls = new Map | undefined; content: IToolResultContent[] }>(); + private readonly _activeToolCalls = new Map | undefined; content: ToolResultContent[] }>(); /** Pending permission requests awaiting a renderer-side decision. */ private readonly _pendingPermissions = new Map>(); /** Pending user input requests awaiting a renderer-side answer. */ - private readonly _pendingUserInputs = new Map }>; questionId: string }>(); + private readonly _pendingUserInputs = new Map }>; questionId: string }>(); /** File edit tracker for this session. */ private readonly _editTracker: FileEditTracker; /** Session database reference. */ @@ -232,7 +232,7 @@ export class CopilotAgentSession extends Disposable { * Resolves a pending client tool call. Returns `true` if the * toolCallId was found and handled. */ - handleClientToolCallComplete(toolCallId: string, result: IToolCallResult) { + handleClientToolCallComplete(toolCallId: string, result: ToolCallResult) { let deferred = this._pendingClientToolCalls.get(toolCallId); if (!deferred) { deferred = new DeferredPromise(); @@ -308,7 +308,7 @@ export class CopilotAgentSession extends Disposable { this._logService.info(`[Copilot:${this.sessionId}] session.send() returned`); } - async sendSteering(steeringMessage: IPendingMessage): Promise { + async sendSteering(steeringMessage: PendingMessage): Promise { this._logService.info(`[Copilot:${this.sessionId}] Sending steering message: "${steeringMessage.userMessage.text.substring(0, 100)}"`); try { await this._wrapper.session.send({ @@ -391,7 +391,7 @@ export class CopilotAgentSession extends Disposable { // Derive display information from the permission request kind const { confirmationTitle, invocationMessage, toolInput, permissionKind, permissionPath } = getPermissionDisplay(request); - // For write permission requests, build an IFileEdit preview so the + // For write permission requests, build an FileEdit preview so the // client can show a diff before the user approves or denies. This // awaits async filesystem operations; the SDK already calls // `handlePermissionRequest` from an arbitrary async context, so the @@ -451,7 +451,7 @@ export class CopilotAgentSession extends Disposable { } /** - * Builds an {@link IFileEdit} preview for a write permission request. + * Builds an {@link FileEdit} preview for a write permission request. * * The `before` side references the existing file on disk directly (if it * exists); the `after` side is written to the `pending-edit-content:` @@ -463,7 +463,7 @@ export class CopilotAgentSession extends Disposable { * the in-memory write completes (e.g. the session was aborted), the * just-written entry is deleted so it cannot leak. */ - private async _buildEditsForPermission(request: ITypedPermissionRequest, toolCallId: string): Promise<{ items: IFileEdit[] } | undefined> { + private async _buildEditsForPermission(request: ITypedPermissionRequest, toolCallId: string): Promise<{ items: FileEdit[] } | undefined> { if (request.kind !== 'write') { return undefined; } @@ -504,7 +504,7 @@ export class CopilotAgentSession extends Disposable { const diffCounts = typeof request.diff === 'string' ? countUnifiedDiffLines(request.diff) : undefined; - const edit: IFileEdit = { + const edit: FileEdit = { ...(beforeExists ? { before: { uri: fileUriStr, content: { uri: fileUriStr } } } : {}), after: { uri: fileUriStr, content: { uri: afterUri.toString() } }, ...(diffCounts ? { diff: diffCounts } : {}), @@ -540,11 +540,11 @@ export class CopilotAgentSession extends Disposable { const questionId = generateUuid(); this._logService.info(`[Copilot:${this.sessionId}] User input request: requestId=${requestId}, question="${questionPreview}"`); - const deferred = new DeferredPromise<{ response: SessionInputResponseKind; answers?: Record }>(); + const deferred = new DeferredPromise<{ response: SessionInputResponseKind; answers?: Record }>(); this._pendingUserInputs.set(requestId, { deferred, questionId }); - // Build the protocol ISessionInputRequest from the SDK's simple format - const inputRequest: ISessionInputRequest = { + // Build the protocol SessionInputRequest from the SDK's simple format + const inputRequest: SessionInputRequest = { id: requestId, message: request.question, questions: [request.choices && request.choices.length > 0 @@ -599,7 +599,7 @@ export class CopilotAgentSession extends Disposable { } } - respondToUserInputRequest(requestId: string, response: SessionInputResponseKind, answers?: Record): boolean { + respondToUserInputRequest(requestId: string, response: SessionInputResponseKind, answers?: Record): boolean { const pending = this._pendingUserInputs.get(requestId); if (pending) { this._pendingUserInputs.delete(requestId); @@ -731,7 +731,7 @@ export class CopilotAgentSession extends Disposable { const displayName = tracked.displayName; const toolOutput = e.data.error?.message ?? e.data.result?.content; - const content: IToolResultContent[] = [...tracked.content]; + const content: ToolResultContent[] = [...tracked.content]; if (toolOutput !== undefined) { content.push({ type: ToolResultContentType.Text, text: toolOutput }); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts index 9282fbd2ef529..1f2ad9b357144 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts @@ -11,7 +11,7 @@ import * as platform from '../../../../base/common/platform.js'; import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { ILogService } from '../../../log/common/log.js'; -import { TerminalClaimKind, type ITerminalSessionClaim } from '../../common/state/protocol/state.js'; +import { TerminalClaimKind, type TerminalSessionClaim } from '../../common/state/protocol/state.js'; import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; /** @@ -95,7 +95,7 @@ export class ShellManager { const id = generateUuid(); const terminalUri = `agenthost-terminal://shell/${id}`; - const claim: ITerminalSessionClaim = { + const claim: TerminalSessionClaim = { kind: TerminalClaimKind.Session, session: this._sessionUri.toString(), turnId, diff --git a/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts b/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts index 79c1508ff647d..3fbd7db2f212d 100644 --- a/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts +++ b/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts @@ -10,7 +10,7 @@ import { IFileService } from '../../../files/common/files.js'; import { ILogService } from '../../../log/common/log.js'; import { IDiffComputeService } from '../../common/diffComputeService.js'; import { ISessionDatabase } from '../../common/sessionDataService.js'; -import { FileEditKind, ToolResultContentType, type IToolResultFileEditContent } from '../../common/state/sessionState.js'; +import { FileEditKind, ToolResultContentType, type ToolResultFileEditContent } from '../../common/state/sessionState.js'; const SESSION_DB_SCHEME = 'session-db'; @@ -128,13 +128,13 @@ export class FileEditTracker { /** * Retrieves and removes a completed edit for the given file path, * persists it to the session database with computed diff counts, - * and returns the result as an {@link IToolResultFileEditContent} + * and returns the result as an {@link ToolResultFileEditContent} * for inclusion in the tool result. * * @param toolCallId - The tool call that produced this edit. * @param filePath - Absolute path of the edited file. */ - async takeCompletedEdit(turnId: string, toolCallId: string, filePath: string): Promise { + async takeCompletedEdit(turnId: string, toolCallId: string, filePath: string): Promise { const edit = this._completedEdits.get(filePath); if (!edit) { return undefined; diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index bb8dc599b2e76..1e8e93f530eb1 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -6,7 +6,7 @@ import { URI } from '../../../../base/common/uri.js'; import { IAgentMessageEvent, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js'; -import { ToolResultContentType, type IToolResultContent } from '../../common/state/sessionState.js'; +import { ToolResultContentType, type ToolResultContent } from '../../common/state/sessionState.js'; import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool } from './copilotToolDisplay.js'; import { buildSessionDbUri } from './fileEditTracker.js'; @@ -190,7 +190,7 @@ export async function mapSessionEvents( toolInfoByCallId.delete(d.toolCallId); const displayName = getToolDisplayName(info.toolName); const toolOutput = d.error?.message ?? d.result?.content; - const content: IToolResultContent[] = []; + const content: ToolResultContent[] = []; if (toolOutput !== undefined) { content.push({ type: ToolResultContentType.Text, text: toolOutput }); } diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 74a83a1e191af..282f652575104 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -11,24 +11,24 @@ import { URI } from '../../../base/common/uri.js'; import { ILogService } from '../../log/common/log.js'; import { AHPFileSystemProvider } from '../common/agentHostFileSystemProvider.js'; import { AgentSession, type IAgentService } from '../common/agentService.js'; -import type { ICommandMap } from '../common/state/protocol/messages.js'; -import { IActionEnvelope, INotification, isSessionAction, isTerminalAction, type ISessionAction } from '../common/state/sessionActions.js'; +import type { CommandMap } from '../common/state/protocol/messages.js'; +import { ActionEnvelope, INotification, isSessionAction, isTerminalAction, type SessionAction } from '../common/state/sessionActions.js'; import { MIN_PROTOCOL_VERSION, PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; import { AHP_AUTH_REQUIRED, AHP_PROVIDER_NOT_FOUND, AHP_SESSION_NOT_FOUND, AHP_UNSUPPORTED_PROTOCOL_VERSION, - IJsonRpcRequest, + JsonRpcRequest, isJsonRpcNotification, isJsonRpcRequest, JSON_RPC_INTERNAL_ERROR, JsonRpcErrorCodes, ProtocolError, - type IAhpServerNotification, - type IInitializeParams, - type IJsonRpcResponse, - type IReconnectParams, + type AhpServerNotification, + type InitializeParams, + type JsonRpcResponse, + type ReconnectParams, type IStateSnapshot, } from '../common/state/sessionProtocol.js'; import { ROOT_STATE_URI, SessionStatus } from '../common/state/sessionState.js'; @@ -39,17 +39,17 @@ import { AgentHostStateManager } from './agentHostStateManager.js'; const REPLAY_BUFFER_CAPACITY = 1000; /** Build a JSON-RPC success response suitable for transport.send(). */ -function jsonRpcSuccess(id: number, result: unknown): IJsonRpcResponse { +function jsonRpcSuccess(id: number, result: unknown): JsonRpcResponse { return { jsonrpc: '2.0', id, result }; } /** Build a JSON-RPC error response suitable for transport.send(). */ -function jsonRpcError(id: number, code: number, message: string, data?: unknown): IJsonRpcResponse { +function jsonRpcError(id: number, code: number, message: string, data?: unknown): JsonRpcResponse { return { jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined ? { data } : {}) } }; } /** Build a JSON-RPC error response from an unknown thrown value, preserving {@link ProtocolError} fields. */ -function jsonRpcErrorFrom(id: number, err: unknown): IJsonRpcResponse { +function jsonRpcErrorFrom(id: number, err: unknown): JsonRpcResponse { if (err instanceof ProtocolError) { return jsonRpcError(id, err.code, err.message, err.data); } @@ -61,7 +61,7 @@ function jsonRpcErrorFrom(id: number, err: unknown): IJsonRpcResponse { * Methods handled by the request dispatcher. Excludes `initialize` and * `reconnect` which are handled during the handshake phase. */ -type RequestMethod = Exclude; +type RequestMethod = Exclude; /** * Typed handler map: each key is a request method, each value is a handler @@ -69,7 +69,7 @@ type RequestMethod = Exclude; * result. The compiler will error if a handler returns the wrong shape. */ type RequestHandlerMap = { - [M in RequestMethod]: (client: IConnectedClient, params: ICommandMap[M]['params']) => Promise; + [M in RequestMethod]: (client: IConnectedClient, params: CommandMap[M]['params']) => Promise; }; /** @@ -99,7 +99,7 @@ export interface IProtocolServerConfig { export class ProtocolServerHandler extends Disposable { private readonly _clients = new Map(); - private readonly _replayBuffer: IActionEnvelope[] = []; + private readonly _replayBuffer: ActionEnvelope[] = []; private readonly _onDidChangeConnectionCount = this._register(new Emitter()); @@ -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 ISessionAction; + const action = msg.params.action as SessionAction; this._agentService.dispatchAction(action, client.clientId, msg.params.clientSeq); } break; @@ -215,7 +215,7 @@ export class ProtocolServerHandler extends Disposable { // ---- Handshake handlers ---------------------------------------------------- private _handleInitialize( - params: IInitializeParams, + params: InitializeParams, transport: IProtocolTransport, disposables: DisposableStore, ): { client: IConnectedClient; response: unknown } { @@ -270,7 +270,7 @@ export class ProtocolServerHandler extends Disposable { } private _handleReconnect( - params: IReconnectParams, + params: ReconnectParams, transport: IProtocolTransport, disposables: DisposableStore, ): { client: IConnectedClient; response: unknown } { @@ -290,7 +290,7 @@ export class ProtocolServerHandler extends Disposable { const canReplay = params.lastSeenServerSeq >= oldestBuffered; if (canReplay) { - const actions: IActionEnvelope[] = []; + const actions: ActionEnvelope[] = []; for (const sub of params.subscriptions) { client.subscriptions.add(sub.toString()); } @@ -497,7 +497,7 @@ export class ProtocolServerHandler extends Disposable { const id = ++this._reverseRequestId; return new Promise((resolve, reject) => { this._pendingReverseRequests.set(id, { clientId, resolve: resolve as (value: unknown) => void, reject }); - const request: IJsonRpcRequest = { jsonrpc: '2.0', id, method, params }; + const request: JsonRpcRequest = { jsonrpc: '2.0', id, method, params }; client.transport.send(request); }); } @@ -558,9 +558,9 @@ export class ProtocolServerHandler extends Disposable { // ---- Broadcasting ------------------------------------------------------- - private _broadcastAction(envelope: IActionEnvelope): void { + private _broadcastAction(envelope: ActionEnvelope): void { this._logService.trace(`[ProtocolServer] Broadcasting action: ${envelope.action.type}`); - const msg: IAhpServerNotification<'action'> = { jsonrpc: '2.0', method: 'action', params: envelope }; + const msg: AhpServerNotification<'action'> = { jsonrpc: '2.0', method: 'action', params: envelope }; for (const client of this._clients.values()) { if (this._isRelevantToClient(client, envelope)) { client.transport.send(msg); @@ -569,13 +569,13 @@ export class ProtocolServerHandler extends Disposable { } private _broadcastNotification(notification: INotification): void { - const msg: IAhpServerNotification<'notification'> = { jsonrpc: '2.0', method: 'notification', params: { notification } }; + const msg: AhpServerNotification<'notification'> = { jsonrpc: '2.0', method: 'notification', params: { notification } }; for (const client of this._clients.values()) { client.transport.send(msg); } } - private _isRelevantToClient(client: IConnectedClient, envelope: IActionEnvelope): boolean { + private _isRelevantToClient(client: IConnectedClient, envelope: ActionEnvelope): boolean { const action = envelope.action; if (action.type.startsWith('root/')) { return client.subscriptions.has(ROOT_STATE_URI); diff --git a/src/vs/platform/agentHost/node/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts index 5c80593ddc8b8..76b41fb7064ce 100644 --- a/src/vs/platform/agentHost/node/sessionDatabase.ts +++ b/src/vs/platform/agentHost/node/sessionDatabase.ts @@ -195,6 +195,19 @@ export class SessionDatabase implements ISessionDatabase { protected _closed: Promise | true | undefined; private readonly _fileEditSequencer = new SequencerByKey(); + /** + * Serializes `setMetadata` writes per key. `@vscode/sqlite3` runs in + * parallelized mode, so two `db.run()` calls on the same connection + * can be dispatched to the libuv thread pool and complete out of + * submission order. For "last writer wins" keys (notably `configValues` + * via {@link setMetadata}), that meant a fast-following second write + * could be overtaken by the first and silently lose its value — see + * the "Session Config persistence across restarts" integration test. + * Sequencing by key preserves intra-key order while still allowing + * writes for different keys to run concurrently. + */ + private readonly _metadataSequencer = new SequencerByKey(); + /** * In-flight write operations. Tracked so {@link whenIdle} can await them * before the process exits — without this, a `SIGTERM` arriving between @@ -483,10 +496,10 @@ export class SessionDatabase implements ISessionDatabase { } setMetadata(key: string, value: string): Promise { - return this._track(async () => { + return this._track(() => this._metadataSequencer.queue(key, async () => { const db = await this._ensureDb(); await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)', [key, value]); - }); + })); } remapTurnIds(mapping: ReadonlyMap): Promise { diff --git a/src/vs/platform/agentHost/node/sessionPermissions.ts b/src/vs/platform/agentHost/node/sessionPermissions.ts new file mode 100644 index 0000000000000..87bc9105c6cf6 --- /dev/null +++ b/src/vs/platform/agentHost/node/sessionPermissions.ts @@ -0,0 +1,299 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { match as globMatch } from '../../../base/common/glob.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { extUriBiasedIgnorePathCase, normalizePath } from '../../../base/common/resources.js'; +import { URI } from '../../../base/common/uri.js'; +import { localize } from '../../../nls.js'; +import { ILogService } from '../../log/common/log.js'; +import type { IAgentToolReadyEvent } from '../common/agentService.js'; +import { ConfirmationOptionKind, type ConfirmationOption } from '../common/state/protocol/state.js'; +import { ActionType, type IToolCallReadyAction } from '../common/state/sessionActions.js'; +import { + ResponsePartKind, + ToolCallConfirmationReason, + parseSubagentSessionUri, + type URI as ProtocolURI, +} from '../common/state/sessionState.js'; +import { AgentHostStateManager } from './agentHostStateManager.js'; +import { CommandAutoApprover } from './commandAutoApprover.js'; + +/** + * Event fields needed for auto-approval decisions. + * Matches the subset of {@link IAgentToolReadyEvent} used by the + * approval pipeline. + */ +export interface IToolApprovalEvent { + readonly toolCallId: string; + readonly session: URI; + readonly permissionKind?: IAgentToolReadyEvent['permissionKind']; + readonly permissionPath?: string; + readonly toolInput?: string; +} + +/** + * Single entry point for all tool-call approval logic in the agent host. + * + * Modeled after {@link ILanguageModelToolsConfirmationService} in the + * workbench layer, this manager owns: + * + * - **Auto-approval** (`getAutoApproval`) — checks session-level config, + * per-tool session permissions, read/write path rules, and shell + * command rules. Returns a {@link ToolCallConfirmationReason} when + * the tool should be auto-approved, or `undefined` when user + * confirmation is needed. + * + * - **Confirmation options** (`createToolReadyAction`) — constructs the + * protocol action with the standard "Allow Once / Allow in this + * Session / Skip" options baked in. + * + * - **Post-confirmation side effects** (`handleToolCallConfirmed`) — + * persists the user's choice (e.g. adding a tool to the session + * permissions list). + */ +export class SessionPermissionManager extends Disposable { + + static readonly PERMISSIONS_CONFIG_KEY = 'permissions'; + static readonly ALLOW_SESSION_OPTION_ID = 'allow-session'; + + private static readonly _CONFIRMATION_OPTIONS: readonly ConfirmationOption[] = [ + { id: SessionPermissionManager.ALLOW_SESSION_OPTION_ID, label: localize('sessionPermissions.allowSession', "Allow in this Session"), kind: ConfirmationOptionKind.Approve, group: 1 }, + { id: 'allow-once', label: localize('sessionPermissions.allowOnce', "Allow Once"), kind: ConfirmationOptionKind.Approve }, + { id: 'skip', label: localize('sessionPermissions.skip', "Skip"), kind: ConfirmationOptionKind.Deny, group: 2 }, + ]; + + // ---- Edit auto-approve patterns ----------------------------------------- + + private static readonly _DEFAULT_EDIT_AUTO_APPROVE_PATTERNS: Readonly> = { + '**/*': true, + '**/.vscode/*.json': false, + '**/.git/**': false, + '**/{package.json,server.xml,build.rs,web.config,.gitattributes,.env}': false, + '**/*.{code-workspace,csproj,fsproj,vbproj,vcxproj,proj,targets,props}': false, + '**/*.lock': false, + '**/*-lock.{yaml,json}': false, + }; + + private readonly _commandAutoApprover: CommandAutoApprover; + + constructor( + private readonly _stateManager: AgentHostStateManager, + private readonly _logService: ILogService, + ) { + super(); + this._commandAutoApprover = this._register(new CommandAutoApprover(this._logService)); + } + + /** + * Initializes async resources (tree-sitter WASM) used for shell command + * auto-approval. Await this before any session events can arrive to + * guarantee that {@link getAutoApproval} is fully synchronous. + */ + initialize(): Promise { + return this._commandAutoApprover.initialize(); + } + + // ---- Auto-approval (analogous to getPreConfirmAction) ------------------- + + /** + * Synchronously checks whether a `tool_ready` event should be + * auto-approved. Returns a {@link ToolCallConfirmationReason} when the + * tool call should proceed without user interaction, or `undefined` + * when user confirmation is required. + * + * Checks are evaluated in order: + * 1. Session-level bypass (`autoApprove` / `autopilot` config) + * 2. Per-tool session permissions (`permissions.allow`) + * 3. Read path rules (within working directory) + * 4. Write path rules (within working directory + glob patterns) + * 5. Shell command rules (tree-sitter parsed, default allow/deny) + */ + getAutoApproval(e: IToolApprovalEvent, sessionKey: ProtocolURI): ToolCallConfirmationReason | undefined { + const { autoApproveLevel, workDir } = this._getInheritedConfig(sessionKey); + + // 1. Session-level auto-approve + if (autoApproveLevel === 'autoApprove' || autoApproveLevel === 'autopilot') { + this._logService.trace(`[SessionPermissionManager] Auto-approving tool call (session autoApprove=${autoApproveLevel})`); + return ToolCallConfirmationReason.Setting; + } + + // 2. Per-tool session permissions + if (this._isToolAllowedByPermissions(sessionKey, e.toolCallId)) { + return ToolCallConfirmationReason.Setting; + } + + // 3. Read auto-approval + if (e.permissionKind === 'read' && e.permissionPath) { + if (this._isPathInWorkingDirectory(e.permissionPath, workDir)) { + this._logService.trace(`[SessionPermissionManager] Auto-approving read of ${e.permissionPath}`); + return ToolCallConfirmationReason.NotNeeded; + } + return undefined; + } + + // 4. Write auto-approval + if (e.permissionKind === 'write' && e.permissionPath) { + if (this._isPathInWorkingDirectory(e.permissionPath, workDir) && this._isEditAutoApproved(e.permissionPath)) { + this._logService.trace(`[SessionPermissionManager] Auto-approving write to ${e.permissionPath}`); + return ToolCallConfirmationReason.NotNeeded; + } + return undefined; + } + + // 5. Shell auto-approval + if (e.permissionKind === 'shell' && e.toolInput) { + const result = this._commandAutoApprover.shouldAutoApprove(e.toolInput); + if (result === 'approved') { + this._logService.trace('[SessionPermissionManager] Auto-approving shell command'); + return ToolCallConfirmationReason.NotNeeded; + } + if (result === 'denied') { + this._logService.trace('[SessionPermissionManager] Shell command denied by rule'); + } + return undefined; + } + + return undefined; + } + + // ---- Action construction (analogous to getPreConfirmActions) ------------- + + /** + * Constructs a `SessionToolCallReady` action from an agent `tool_ready` + * event. When the tool needs user confirmation (`confirmationTitle` is + * set), the standard confirmation options are included in the action so + * clients can render them directly. + */ + createToolReadyAction(e: IAgentToolReadyEvent, sessionKey: ProtocolURI, turnId: string): IToolCallReadyAction { + if (e.confirmationTitle) { + return { + type: ActionType.SessionToolCallReady, + session: sessionKey, + turnId, + toolCallId: e.toolCallId, + invocationMessage: e.invocationMessage, + toolInput: e.toolInput, + confirmationTitle: e.confirmationTitle, + edits: e.edits, + options: SessionPermissionManager._CONFIRMATION_OPTIONS.slice(), + }; + } + return { + type: ActionType.SessionToolCallReady, + session: sessionKey, + turnId, + toolCallId: e.toolCallId, + invocationMessage: e.invocationMessage, + toolInput: e.toolInput, + confirmed: ToolCallConfirmationReason.NotNeeded, + }; + } + + // ---- Post-confirmation side effects ------------------------------------- + + /** + * Handles the side effect of a `SessionToolCallConfirmed` action when the + * user selected "Allow in this Session". Adds the tool to the session's + * permission allow list so future calls are auto-approved. + */ + handleToolCallConfirmed(sessionKey: ProtocolURI, toolCallId: string, selectedOptionId: string | undefined): void { + if (selectedOptionId === SessionPermissionManager.ALLOW_SESSION_OPTION_ID) { + const toolName = this._getToolNameForToolCall(sessionKey, toolCallId); + if (toolName) { + this._addToolToSessionPermissions(sessionKey, toolName); + } + } + } + + // ---- Internal helpers --------------------------------------------------- + + private _getInheritedConfig(sessionKey: ProtocolURI): { autoApproveLevel: unknown | undefined; workDir: string | undefined } { + const sessionState = this._stateManager.getSessionState(sessionKey); + const parentInfo = parseSubagentSessionUri(sessionKey); + const parentState = parentInfo ? this._stateManager.getSessionState(parentInfo.parentSession) : undefined; + return { + autoApproveLevel: sessionState?.config?.values?.autoApprove ?? parentState?.config?.values?.autoApprove, + workDir: sessionState?.summary.workingDirectory ?? parentState?.summary.workingDirectory, + }; + } + + private _isPathInWorkingDirectory(filePath: string, workDir: string | undefined): boolean { + if (!workDir) { + return false; + } + const workingDirectory = URI.parse(workDir); + return extUriBiasedIgnorePathCase.isEqualOrParent(normalizePath(URI.file(filePath)), workingDirectory); + } + + private _isEditAutoApproved(filePath: string): boolean { + const patterns = SessionPermissionManager._DEFAULT_EDIT_AUTO_APPROVE_PATTERNS; + let approved = true; + for (const [pattern, isApproved] of Object.entries(patterns)) { + if (isApproved !== approved && globMatch(pattern, filePath)) { + approved = isApproved; + } + } + return approved; + } + + private _isToolAllowedByPermissions(sessionKey: ProtocolURI, toolCallId: string): boolean { + const toolName = this._getToolNameForToolCall(sessionKey, toolCallId); + if (!toolName) { + return false; + } + const allowed = this._getPermissions(sessionKey).allow.includes(toolName); + if (allowed) { + this._logService.trace(`[SessionPermissionManager] Auto-approving "${toolName}" via session permissions`); + } + return allowed; + } + + private _getPermissions(sessionKey: ProtocolURI): { allow: string[]; deny: string[] } { + const sessionState = this._stateManager.getSessionState(sessionKey); + const parentInfo = parseSubagentSessionUri(sessionKey); + const parentState = parentInfo ? this._stateManager.getSessionState(parentInfo.parentSession) : undefined; + const raw = sessionState?.config?.values?.[SessionPermissionManager.PERMISSIONS_CONFIG_KEY] + ?? parentState?.config?.values?.[SessionPermissionManager.PERMISSIONS_CONFIG_KEY]; + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + const obj = raw as Record; + return { + allow: Array.isArray(obj.allow) ? obj.allow.filter((v): v is string => typeof v === 'string') : [], + deny: Array.isArray(obj.deny) ? obj.deny.filter((v): v is string => typeof v === 'string') : [], + }; + } + return { allow: [], deny: [] }; + } + + private _getToolNameForToolCall(sessionKey: ProtocolURI, toolCallId: string): string | undefined { + const sessionState = this._stateManager.getSessionState(sessionKey); + const parts = sessionState?.activeTurn?.responseParts; + if (!parts) { + return undefined; + } + for (const rp of parts) { + if (rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === toolCallId) { + return rp.toolCall.toolName; + } + } + return undefined; + } + + private _addToolToSessionPermissions(sessionKey: ProtocolURI, toolName: string): void { + const permissions = this._getPermissions(sessionKey); + if (permissions.allow.includes(toolName)) { + return; + } + permissions.allow.push(toolName); + this._stateManager.dispatchServerAction({ + type: ActionType.SessionConfigChanged, + session: sessionKey, + config: { + [SessionPermissionManager.PERMISSIONS_CONFIG_KEY]: permissions, + }, + }); + this._logService.info(`[SessionPermissionManager] Added "${toolName}" to session permissions for ${sessionKey}`); + } +} diff --git a/src/vs/platform/agentHost/node/webSocketTransport.ts b/src/vs/platform/agentHost/node/webSocketTransport.ts index 42aef7bdfc79f..b0ba32926962c 100644 --- a/src/vs/platform/agentHost/node/webSocketTransport.ts +++ b/src/vs/platform/agentHost/node/webSocketTransport.ts @@ -10,7 +10,7 @@ import { Emitter } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { connectionTokenQueryName } from '../../../base/common/network.js'; import { ILogService } from '../../log/common/log.js'; -import { JSON_RPC_PARSE_ERROR, type IAhpServerNotification, type IJsonRpcResponse, type IProtocolMessage } from '../common/state/sessionProtocol.js'; +import { JSON_RPC_PARSE_ERROR, type AhpServerNotification, type JsonRpcResponse, type ProtocolMessage } from '../common/state/sessionProtocol.js'; import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js'; import type * as wsTypes from 'ws'; import type * as httpTypes from 'http'; @@ -42,7 +42,7 @@ export interface IWebSocketServerOptions { */ export class WebSocketProtocolTransport extends Disposable implements IProtocolTransport { - private readonly _onMessage = this._register(new Emitter()); + private readonly _onMessage = this._register(new Emitter()); readonly onMessage = this._onMessage.event; private readonly _onClose = this._register(new Emitter()); @@ -57,7 +57,7 @@ export class WebSocketProtocolTransport extends Disposable implements IProtocolT this._ws.on('message', (data: Buffer | string) => { try { const text = typeof data === 'string' ? data : data.toString('utf-8'); - const message = JSON.parse(text) as IProtocolMessage; + const message = JSON.parse(text) as ProtocolMessage; this._onMessage.fire(message); } catch { this.send({ jsonrpc: '2.0', id: null!, error: { code: JSON_RPC_PARSE_ERROR, message: 'Parse error' } }); @@ -74,7 +74,7 @@ export class WebSocketProtocolTransport extends Disposable implements IProtocolT }); } - send(message: IProtocolMessage | IAhpServerNotification | IJsonRpcResponse): void { + send(message: ProtocolMessage | AhpServerNotification | JsonRpcResponse): void { if (this._ws.readyState === this._WebSocket.OPEN) { this._ws.send(JSON.stringify(message)); } diff --git a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts index 1cc4cf0ee005c..ccd2015f10089 100644 --- a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts +++ b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts @@ -7,14 +7,14 @@ import assert from 'assert'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { ActionType, type IActionEnvelope } from '../../common/state/sessionActions.js'; -import { SessionLifecycle, SessionStatus, TerminalClaimKind, type IRootState, type ISessionState, type ITerminalState } from '../../common/state/protocol/state.js'; +import { ActionType, type ActionEnvelope } from '../../common/state/sessionActions.js'; +import { SessionLifecycle, SessionStatus, TerminalClaimKind, type RootState, type SessionState, type TerminalState } from '../../common/state/protocol/state.js'; import { StateComponents } from '../../common/state/sessionState.js'; import { AgentSubscriptionManager, RootStateSubscription, SessionStateSubscription, TerminalStateSubscription } from '../../common/state/agentSubscription.js'; // Helpers -function makeRootState(overrides?: Partial): IRootState { +function makeRootState(overrides?: Partial): RootState { return { agents: [], activeSessions: 0, @@ -23,7 +23,7 @@ function makeRootState(overrides?: Partial): IRootState { }; } -function makeSessionState(sessionUri: string, overrides?: Partial): ISessionState { +function makeSessionState(sessionUri: string, overrides?: Partial): SessionState { return { summary: { resource: sessionUri, @@ -40,7 +40,7 @@ function makeSessionState(sessionUri: string, overrides?: Partial }; } -function makeTerminalState(overrides?: Partial): ITerminalState { +function makeTerminalState(overrides?: Partial): TerminalState { return { title: 'bash', content: [], @@ -49,7 +49,7 @@ function makeTerminalState(overrides?: Partial): ITerminalState }; } -function makeEnvelope(action: IActionEnvelope['action'], serverSeq: number, origin?: IActionEnvelope['origin'], rejectionReason?: string): IActionEnvelope { +function makeEnvelope(action: ActionEnvelope['action'], serverSeq: number, origin?: ActionEnvelope['origin'], rejectionReason?: string): ActionEnvelope { return { action, serverSeq, origin, rejectionReason }; } @@ -89,7 +89,7 @@ suite('RootStateSubscription', () => { test('handleSnapshot fires onDidChange', () => { const sub = disposables.add(new RootStateSubscription('c1', noop)); - const fired: IRootState[] = []; + const fired: RootState[] = []; disposables.add(sub.onDidChange(s => fired.push(s))); sub.handleSnapshot(makeRootState(), 0); assert.strictEqual(fired.length, 1); @@ -102,7 +102,7 @@ suite('RootStateSubscription', () => { { type: ActionType.RootActiveSessionsChanged, activeSessions: 5 }, 1, )); - assert.strictEqual((sub.value as IRootState).activeSessions, 5); + assert.strictEqual((sub.value as RootState).activeSessions, 5); }); test('ignores non-root actions', () => { @@ -140,7 +140,7 @@ suite('RootStateSubscription', () => { // Now apply snapshot with fromSeq=1; envelope at seq 2 should replay sub.handleSnapshot(makeRootState(), 1); - assert.strictEqual((sub.value! as IRootState).activeSessions, 7); + assert.strictEqual((sub.value! as RootState).activeSessions, 7); }); test('buffered envelopes with serverSeq <= fromSeq are discarded', () => { @@ -151,7 +151,7 @@ suite('RootStateSubscription', () => { )); sub.handleSnapshot(makeRootState({ activeSessions: 0 }), 1); // Envelope at seq 1 should not replay since fromSeq === 1 - assert.strictEqual((sub.value as IRootState).activeSessions, 0); + assert.strictEqual((sub.value as RootState).activeSessions, 0); }); test('setError makes value return the error', () => { @@ -212,7 +212,7 @@ suite('SessionStateSubscription', () => { }); assert.strictEqual(clientSeq, 1); - assert.strictEqual((sub.value as ISessionState).summary.title, 'Optimistic'); + assert.strictEqual((sub.value as SessionState).summary.title, 'Optimistic'); // verifiedValue should remain unchanged assert.strictEqual(sub.verifiedValue!.summary.title, 'Test'); }); @@ -237,7 +237,7 @@ suite('SessionStateSubscription', () => { // After confirmation, verifiedValue should match assert.strictEqual(sub.verifiedValue!.summary.title, 'Optimistic'); // No pending, value falls through to confirmed - assert.strictEqual((sub.value as ISessionState).summary.title, 'Optimistic'); + assert.strictEqual((sub.value as SessionState).summary.title, 'Optimistic'); }); test('rejected own action removes pending without updating confirmed', () => { @@ -261,7 +261,7 @@ suite('SessionStateSubscription', () => { // Confirmed state unchanged assert.strictEqual(sub.verifiedValue!.summary.title, 'Test'); // No more pending, value = confirmed - assert.strictEqual((sub.value as ISessionState).summary.title, 'Test'); + assert.strictEqual((sub.value as SessionState).summary.title, 'Test'); }); test('foreign action updates confirmed and recomputes optimistic', () => { @@ -285,7 +285,7 @@ suite('SessionStateSubscription', () => { // Confirmed state should have SessionReady applied assert.strictEqual(sub.verifiedValue!.lifecycle, SessionLifecycle.Ready); // Optimistic should still have 'Local' title on top - assert.strictEqual((sub.value as ISessionState).summary.title, 'Local'); + assert.strictEqual((sub.value as SessionState).summary.title, 'Local'); }); test('after all pending cleared, value falls through to verifiedValue', () => { @@ -319,12 +319,12 @@ suite('SessionStateSubscription', () => { title: 'Pending', }); - assert.strictEqual((sub.value as ISessionState).summary.title, 'Pending'); + assert.strictEqual((sub.value as SessionState).summary.title, 'Pending'); sub.clearPending(); // Should fall back to confirmed - assert.strictEqual((sub.value as ISessionState).summary.title, 'Test'); + assert.strictEqual((sub.value as SessionState).summary.title, 'Test'); }); test('ignores actions for different session', () => { @@ -336,7 +336,7 @@ suite('SessionStateSubscription', () => { 1, )); - assert.strictEqual((sub.value as ISessionState).summary.title, 'Test'); + assert.strictEqual((sub.value as SessionState).summary.title, 'Test'); }); test('buffers envelopes before snapshot and replays after', () => { @@ -351,14 +351,14 @@ suite('SessionStateSubscription', () => { sub.handleSnapshot(makeSessionState(sessionUri), 1); - assert.strictEqual((sub.value! as ISessionState).summary.title, 'Buffered'); + assert.strictEqual((sub.value! as SessionState).summary.title, 'Buffered'); }); test('fires onDidChange on optimistic apply', () => { const sub = createSub(); sub.handleSnapshot(makeSessionState(sessionUri), 0); - const fired: ISessionState[] = []; + const fired: SessionState[] = []; disposables.add(sub.onDidChange(s => fired.push(s))); sub.applyOptimistic({ @@ -397,7 +397,7 @@ suite('TerminalStateSubscription', () => { 1, )); - assert.deepStrictEqual((sub.value as ITerminalState).content, [ + assert.deepStrictEqual((sub.value as TerminalState).content, [ { type: 'unclassified', value: 'hello' }, ]); }); @@ -411,7 +411,7 @@ suite('TerminalStateSubscription', () => { 1, )); - assert.deepStrictEqual((sub.value as ITerminalState).content, []); + assert.deepStrictEqual((sub.value as TerminalState).content, []); }); test('ignores non-terminal actions', () => { @@ -423,7 +423,7 @@ suite('TerminalStateSubscription', () => { 1, )); - assert.deepStrictEqual((sub.value as ITerminalState).content, []); + assert.deepStrictEqual((sub.value as TerminalState).content, []); }); test('handleSnapshot sets value', () => { @@ -491,7 +491,7 @@ suite('AgentSubscriptionManager', () => { test('getSubscription returns IReference with subscription', async () => { const mgr = createManager(); const uri = URI.parse(sessionUri); - const ref = mgr.getSubscription(StateComponents.Session, uri); + const ref = mgr.getSubscription(StateComponents.Session, uri); assert.ok(ref.object); assert.strictEqual(ref.object.value, undefined); // not yet initialized (async) @@ -506,8 +506,8 @@ suite('AgentSubscriptionManager', () => { test('second call for same resource increments refcount', async () => { const mgr = createManager(); const uri = URI.parse(sessionUri); - const ref1 = mgr.getSubscription(StateComponents.Session, uri); - const ref2 = mgr.getSubscription(StateComponents.Session, uri); + const ref1 = mgr.getSubscription(StateComponents.Session, uri); + const ref2 = mgr.getSubscription(StateComponents.Session, uri); await new Promise(r => setTimeout(r, 0)); @@ -526,7 +526,7 @@ suite('AgentSubscriptionManager', () => { test('disposing last ref calls unsubscribe callback', async () => { const mgr = createManager(); const uri = URI.parse(sessionUri); - const ref = mgr.getSubscription(StateComponents.Session, uri); + const ref = mgr.getSubscription(StateComponents.Session, uri); await new Promise(r => setTimeout(r, 0)); @@ -539,7 +539,7 @@ suite('AgentSubscriptionManager', () => { mgr.handleRootSnapshot(makeRootState(), 0); const uri = URI.parse(sessionUri); - const ref = mgr.getSubscription(StateComponents.Session, uri); + const ref = mgr.getSubscription(StateComponents.Session, uri); await new Promise(r => setTimeout(r, 0)); // Send a root action @@ -547,14 +547,14 @@ suite('AgentSubscriptionManager', () => { { type: ActionType.RootActiveSessionsChanged, activeSessions: 10 }, 1, )); - assert.strictEqual((mgr.rootState.value as IRootState).activeSessions, 10); + assert.strictEqual((mgr.rootState.value as RootState).activeSessions, 10); // Send a session action mgr.receiveEnvelope(makeEnvelope( { type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Routed' }, 2, )); - assert.strictEqual((ref.object.value as ISessionState).summary.title, 'Routed'); + assert.strictEqual((ref.object.value as SessionState).summary.title, 'Routed'); ref.dispose(); }); @@ -562,7 +562,7 @@ suite('AgentSubscriptionManager', () => { test('creating session subscription for copilot: URI', async () => { const mgr = createManager(); const mySessionUri = URI.from({ scheme: 'copilot', path: '/my-session' }); - const ref = mgr.getSubscription(StateComponents.Session, mySessionUri); + const ref = mgr.getSubscription(StateComponents.Session, mySessionUri); await new Promise(r => setTimeout(r, 0)); assert.ok(ref.object.value); @@ -574,7 +574,7 @@ suite('AgentSubscriptionManager', () => { test('creating terminal subscription for terminal URI', async () => { const mgr = createManager(); const uri = URI.parse(terminalUri); - const ref = mgr.getSubscription(StateComponents.Terminal, uri); + const ref = mgr.getSubscription(StateComponents.Terminal, uri); await new Promise(r => setTimeout(r, 0)); assert.ok(ref.object.value); @@ -586,7 +586,7 @@ suite('AgentSubscriptionManager', () => { test('dispatchOptimistic applies to matching session subscription', async () => { const mgr = createManager(); const uri = URI.parse(sessionUri); - const ref = mgr.getSubscription(StateComponents.Session, uri); + const ref = mgr.getSubscription(StateComponents.Session, uri); await new Promise(r => setTimeout(r, 0)); const clientSeq = mgr.dispatchOptimistic({ @@ -596,7 +596,7 @@ suite('AgentSubscriptionManager', () => { }); assert.ok(clientSeq > 0); - assert.strictEqual((ref.object.value as ISessionState).summary.title, 'Dispatched'); + assert.strictEqual((ref.object.value as SessionState).summary.title, 'Dispatched'); // verifiedValue unchanged assert.strictEqual(ref.object.verifiedValue!.summary.title, 'Test'); @@ -606,8 +606,8 @@ suite('AgentSubscriptionManager', () => { test('dispose clears all subscriptions and calls unsubscribe for each', async () => { const mgr = createManager(); - const ref1 = mgr.getSubscription(StateComponents.Session, URI.parse(sessionUri)); - const ref2 = mgr.getSubscription(StateComponents.Terminal, URI.parse(terminalUri)); + const ref1 = mgr.getSubscription(StateComponents.Session, URI.parse(sessionUri)); + const ref2 = mgr.getSubscription(StateComponents.Terminal, URI.parse(terminalUri)); await new Promise(r => setTimeout(r, 0)); // Remove the manager from disposables so we can dispose it manually @@ -625,7 +625,7 @@ suite('AgentSubscriptionManager', () => { test('getSubscriptionUnmanaged returns undefined when no subscription exists', () => { const mgr = createManager(); - const result = mgr.getSubscriptionUnmanaged(URI.parse('copilot:/nonexistent')); + const result = mgr.getSubscriptionUnmanaged(URI.parse('copilot:/nonexistent')); assert.strictEqual(result, undefined); }); @@ -634,11 +634,11 @@ suite('AgentSubscriptionManager', () => { const uri = URI.parse(sessionUri); // Create a subscription via getSubscription - const ref = mgr.getSubscription(StateComponents.Session, uri); + const ref = mgr.getSubscription(StateComponents.Session, uri); await new Promise(r => setTimeout(r, 0)); // Get it unmanaged - const unmanaged = mgr.getSubscriptionUnmanaged(uri); + const unmanaged = mgr.getSubscriptionUnmanaged(uri); assert.ok(unmanaged); assert.strictEqual(unmanaged, ref.object); @@ -646,7 +646,7 @@ suite('AgentSubscriptionManager', () => { ref.dispose(); // Now unmanaged should return undefined since it was released - const after = mgr.getSubscriptionUnmanaged(uri); + const after = mgr.getSubscriptionUnmanaged(uri); assert.strictEqual(after, undefined); }); }); diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts index 557d1b36978b6..1173271815cd4 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -13,13 +13,13 @@ import { FileService } from '../../../files/common/fileService.js'; import { NullLogService } from '../../../log/common/log.js'; import { RemoteAgentHostProtocolClient, RemoteAgentHostProtocolError } from '../../browser/remoteAgentHostProtocolClient.js'; import { AhpErrorCodes } from '../../common/state/protocol/errors.js'; -import type { IAhpServerNotification, IJsonRpcNotification, IJsonRpcRequest, IJsonRpcResponse, IProtocolMessage } from '../../common/state/sessionProtocol.js'; +import type { AhpServerNotification, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, ProtocolMessage } from '../../common/state/sessionProtocol.js'; import type { IClientTransport, IProtocolTransport } from '../../common/state/sessionTransport.js'; -type ProtocolTransportMessage = IProtocolMessage | IAhpServerNotification | IJsonRpcNotification | IJsonRpcResponse | IJsonRpcRequest; +type ProtocolTransportMessage = ProtocolMessage | AhpServerNotification | JsonRpcNotification | JsonRpcResponse | JsonRpcRequest; class TestProtocolTransport extends Disposable implements IProtocolTransport { - private readonly _onMessage = this._register(new Emitter()); + private readonly _onMessage = this._register(new Emitter()); readonly onMessage = this._onMessage.event; private readonly _onClose = this._register(new Emitter()); @@ -31,7 +31,7 @@ class TestProtocolTransport extends Disposable implements IProtocolTransport { this.sentMessages.push(message); } - fireMessage(message: IProtocolMessage): void { + fireMessage(message: ProtocolMessage): void { this._onMessage.fire(message); } diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts index e81b578391c3b..7851f50cfc675 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts @@ -437,4 +437,57 @@ suite('RemoteAgentHostService', () => { assert.deepStrictEqual(configService.entries, []); assert.strictEqual(service.connections.length, 0); }); + + suite('addManagedConnection', () => { + + // Build a transport disposable that records when it ran. + function makeTransportDisposable(): { disposable: { dispose(): void }; disposed: () => boolean } { + let disposed = false; + return { + disposable: { dispose: () => { disposed = true; } }, + disposed: () => disposed, + }; + } + + // Inject a managed connection (mimicking the SSH/tunnel renderer flow). + async function addManaged(name: string, address: string, transport?: { dispose(): void }) { + const mockClient = disposables.add(new MockProtocolClient(`ws://${address}`)); + return service.addManagedConnection( + { name, connection: { type: RemoteAgentHostEntryType.WebSocket, address } }, + mockClient as unknown as Parameters[1], + transport, + ); + } + + test('disposes transportDisposable when entry is removed via removeRemoteAgentHost', async () => { + const t = makeTransportDisposable(); + await addManaged('Managed', 'managed:1234', t.disposable); + assert.strictEqual(t.disposed(), false); + + await service.removeRemoteAgentHost('ws://managed:1234'); + + assert.strictEqual(t.disposed(), true, 'transport disposable runs when entry is removed'); + assert.strictEqual(service.getConnection('ws://managed:1234'), undefined); + }); + + test('disposes previous transportDisposable when entry is replaced', async () => { + const t1 = makeTransportDisposable(); + await addManaged('Managed', 'managed:1234', t1.disposable); + + const t2 = makeTransportDisposable(); + await addManaged('Managed', 'managed:1234', t2.disposable); + + assert.strictEqual(t1.disposed(), true, 'first transport disposable runs when entry is replaced'); + assert.strictEqual(t2.disposed(), false, 'second transport disposable is still alive'); + }); + + test('disposes transportDisposable when service itself is disposed', async () => { + const t = makeTransportDisposable(); + await addManaged('Managed', 'managed:1234', t.disposable); + + service.dispose(); + + assert.strictEqual(t.disposed(), true, 'transport disposable runs when service is disposed'); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts new file mode 100644 index 0000000000000..6f993547140bb --- /dev/null +++ b/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts @@ -0,0 +1,303 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DeferredPromise } from '../../../../base/common/async.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import type { IChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../log/common/log.js'; +import { IConfigurationService } from '../../../configuration/common/configuration.js'; +import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; +import { ISharedProcessService } from '../../../ipc/electron-browser/services.js'; +import { IRemoteAgentHostService } from '../../common/remoteAgentHostService.js'; +import type { IAgentConnection } from '../../common/agentService.js'; +import type { + ISSHAgentHostConfig, + ISSHConnectResult, + ISSHRelayMessage, + ISSHResolvedConfig, +} from '../../common/sshRemoteAgentHost.js'; +import { SSHRemoteAgentHostService } from '../../electron-browser/sshRemoteAgentHostServiceImpl.js'; + +/** + * In-renderer mock of the shared-process SSH service. Exposes the same + * surface that the renderer accesses through ProxyChannel, plus a small + * test API to drive close events and inspect calls. + */ +class MockSSHMainService { + private readonly _onDidChangeConnections = new Emitter(); + readonly onDidChangeConnections = this._onDidChangeConnections.event; + + private readonly _onDidCloseConnection = new Emitter(); + readonly onDidCloseConnection = this._onDidCloseConnection.event; + + private readonly _onDidReportConnectProgress = new Emitter<{ connectionKey: string; message: string }>(); + readonly onDidReportConnectProgress = this._onDidReportConnectProgress.event; + + private readonly _onDidRelayMessage = new Emitter(); + readonly onDidRelayMessage = this._onDidRelayMessage.event; + + private readonly _onDidRelayClose = new Emitter(); + readonly onDidRelayClose = this._onDidRelayClose.event; + + readonly disconnectCalls: string[] = []; + private _nextConnectionId = 1; + + connectResult: Partial | undefined; + + async connect(config: ISSHAgentHostConfig): Promise { + const connectionId = this.connectResult?.connectionId ?? `conn-${this._nextConnectionId++}`; + return { + connectionId, + address: this.connectResult?.address ?? `ssh:${config.host}`, + name: config.name, + connectionToken: 'test-token', + config: { host: config.host, username: config.username, authMethod: config.authMethod, name: config.name, sshConfigHost: config.sshConfigHost }, + sshConfigHost: config.sshConfigHost, + }; + } + + async reconnect(sshConfigHost: string, name: string): Promise { + return { + connectionId: `conn-${this._nextConnectionId++}`, + address: `ssh:${sshConfigHost}`, + name, + connectionToken: 'test-token', + config: { host: sshConfigHost, username: 'u', authMethod: 0 as never, name, sshConfigHost }, + sshConfigHost, + }; + } + + async relaySend(_connectionId: string, _message: string): Promise { /* no-op */ } + + async disconnect(connectionId: string): Promise { + this.disconnectCalls.push(connectionId); + } + + async listSSHConfigHosts(): Promise { return []; } + async resolveSSHConfig(_host: string): Promise { + return { hostname: '', user: undefined, port: 22, identityFile: [], forwardAgent: false }; + } + + dispose(): void { + this._onDidChangeConnections.dispose(); + this._onDidCloseConnection.dispose(); + this._onDidReportConnectProgress.dispose(); + this._onDidRelayMessage.dispose(); + this._onDidRelayClose.dispose(); + } +} + +/** Adapt a mock service object to the IChannel surface ProxyChannel expects. */ +function asChannel(target: object): IChannel { + return { + call: async (method: string, args?: unknown): Promise => { + const fn = (target as Record)[method]; + if (typeof fn !== 'function') { + throw new Error(`MockChannel: no method ${method}`); + } + return (fn as (...a: unknown[]) => Promise).apply(target, (args as unknown[]) ?? []); + }, + listen: (event: string): Event => { + const ev = (target as Record)[event]; + if (typeof ev !== 'function') { + throw new Error(`MockChannel: no event ${event}`); + } + return ev as Event; + }, + }; +} + +/** Captures addManagedConnection calls so tests can inspect transportDisposable. */ +class MockRemoteAgentHostService extends Disposable { + readonly added: Array<{ address: string; transport?: IDisposable }> = []; + private readonly _entries = new Map void } }>(); + + async addManagedConnection(entry: { name: string; connection: { address?: string; sshConfigHost?: string } }, client: IAgentConnection, transportDisposable?: IDisposable): Promise { + const address = entry.connection.address ?? `ssh:${entry.connection.sshConfigHost}`; + this.added.push({ address, transport: transportDisposable }); + this._entries.set(address, { client: client as { dispose?: () => void }, transport: transportDisposable }); + return { address, name: entry.name, clientId: 'mock', defaultDirectory: undefined, status: 0 }; + } + + /** Simulate user clicking "Remove Remote": disposes the per-entry store, which runs the transport disposable. */ + removeEntry(address: string): void { + const e = this._entries.get(address); + if (!e) { + return; + } + this._entries.delete(address); + e.client.dispose?.(); + e.transport?.dispose(); + } + + override dispose(): void { + // Dispose any still-registered entries (mirrors the per-entry store cleanup + // done by the real RemoteAgentHostService when it itself is disposed). + for (const [, e] of this._entries) { + e.client.dispose?.(); + e.transport?.dispose(); + } + this._entries.clear(); + super.dispose(); + } +} + +class MockProtocolClient extends Disposable { + readonly clientId = 'mock-protocol-client'; + readonly onDidClose = Event.None; + readonly onDidAction = Event.None; + readonly onDidNotification = Event.None; + readonly connectDeferred = new DeferredPromise(); + async connect(): Promise { return this.connectDeferred.p; } + registerOwned(d: T): T { return this._register(d); } +} + +class TestConfigurationService { + readonly onDidChangeConfiguration = Event.None; + getValue(): unknown { return undefined; } +} + +suite('SSHRemoteAgentHostService (renderer)', () => { + + const disposables = new DisposableStore(); + let mainService: MockSSHMainService; + let remoteAgentHostService: MockRemoteAgentHostService; + let createdClients: MockProtocolClient[]; + let waitForClient: (index: number) => Promise; + let service: SSHRemoteAgentHostService; + + setup(() => { + mainService = new MockSSHMainService(); + disposables.add({ dispose: () => mainService.dispose() }); + remoteAgentHostService = disposables.add(new MockRemoteAgentHostService()); + createdClients = []; + + const sharedProcessService: Partial = { + getChannel: () => asChannel(mainService), + }; + + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IConfigurationService, new TestConfigurationService() as Partial); + instantiationService.stub(ISharedProcessService, sharedProcessService as ISharedProcessService); + instantiationService.stub(IRemoteAgentHostService, remoteAgentHostService as Partial); + + const clientWaiters: DeferredPromise[] = []; + waitForClient = (index: number): Promise => { + if (createdClients[index]) { + return Promise.resolve(createdClients[index]); + } + return (clientWaiters[index] ??= new DeferredPromise()).p; + }; + + const inner: Partial = { + createInstance: (_ctor: unknown, ...args: unknown[]) => { + const c = new MockProtocolClient(); + // The real RemoteAgentHostProtocolClient owns the transport disposable + // it's constructed with; mirror that here so SSHRelayTransport doesn't leak. + const transport = args[1] as IDisposable | undefined; + if (transport) { + c.registerOwned(transport); + } + disposables.add(c); + const index = createdClients.length; + createdClients.push(c); + clientWaiters[index]?.complete(c); + return c; + }, + }; + instantiationService.stub(IInstantiationService, inner as Partial); + + service = disposables.add(instantiationService.createInstance(SSHRemoteAgentHostService)); + }); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + const sampleConfig: ISSHAgentHostConfig = { + host: 'remote.example', + username: 'user', + authMethod: 0 as never, + name: 'My Remote', + sshConfigHost: 'remote.example', + }; + + /** Wait until the renderer has created its protocol client, then resolve its handshake. */ + async function awaitClientThenResolve(index: number): Promise { + const client = await waitForClient(index); + client.connectDeferred.complete(); + } + + test('connect registers a managed connection with a transport disposable', async () => { + const connectPromise = service.connect(sampleConfig); + await awaitClientThenResolve(0); + const handle = await connectPromise; + + assert.strictEqual(remoteAgentHostService.added.length, 1); + assert.strictEqual(remoteAgentHostService.added[0].address, 'ssh:remote.example'); + assert.ok(remoteAgentHostService.added[0].transport, 'a transport disposable is passed so removal can tear down the SSH tunnel'); + assert.strictEqual(service.connections.length, 1); + assert.strictEqual(handle.localAddress, 'ssh:remote.example'); + }); + + test('removing the entry tears down the SSH tunnel and the renderer-side handle', async () => { + const connectPromise = service.connect(sampleConfig); + await awaitClientThenResolve(0); + await connectPromise; + + assert.strictEqual(mainService.disconnectCalls.length, 0); + assert.strictEqual(service.connections.length, 1); + + // Simulate the user clicking "Remove Remote": IRemoteAgentHostService + // disposes the per-entry store, which runs our transport disposable. + remoteAgentHostService.removeEntry('ssh:remote.example'); + + assert.deepStrictEqual(mainService.disconnectCalls, ['conn-1'], 'main-process tunnel is told to disconnect'); + assert.strictEqual(service.connections.length, 0, 'renderer-side handle is dropped'); + }); + + test('connect after removal does not reuse the previous handle', async () => { + // First connect → entry registered, then removed. + const c1 = service.connect(sampleConfig); + await awaitClientThenResolve(0); + await c1; + remoteAgentHostService.removeEntry('ssh:remote.example'); + assert.strictEqual(service.connections.length, 0); + + // Second connect → main returns a new connectionId; renderer creates + // a fresh handle and registers a new managed entry. + mainService.connectResult = { connectionId: 'conn-2', address: 'ssh:remote.example' }; + const c2 = service.connect(sampleConfig); + await awaitClientThenResolve(1); + await c2; + + assert.strictEqual(service.connections.length, 1); + assert.strictEqual(remoteAgentHostService.added.length, 2, 'each connect produces a fresh managed-connection registration'); + }); + + test('main-process onDidCloseConnection cleans up renderer handle without double-disconnecting', async () => { + const connectPromise = service.connect(sampleConfig); + await awaitClientThenResolve(0); + await connectPromise; + assert.strictEqual(service.connections.length, 1); + + // Simulate main process closing the connection on its own (e.g. SSH dropped). + // We can't directly fire on the wrapped emitter through the channel because + // ProxyChannel is one-directional; instead we trigger via the mock service + // emitter that the renderer subscribed to. + (mainService as unknown as { _onDidCloseConnection: Emitter })._onDidCloseConnection.fire('conn-1'); + + assert.strictEqual(service.connections.length, 0, 'handle dropped on main close'); + // Removing the (already-gone) entry shouldn't trigger another disconnect call. + remoteAgentHostService.removeEntry('ssh:remote.example'); + // One disconnect from the transport disposable is fine; we just want to make + // sure we're not at risk of issuing a second one against a stale id. + assert.ok(mainService.disconnectCalls.length <= 1, 'no duplicate disconnect against a stale connectionId'); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts index 2e8055a13a2fe..9572493fa3e4e 100644 --- a/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts +++ b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts @@ -23,9 +23,9 @@ import type { IDeltaAction, IReasoningAction, IResponsePartAction, - ISessionAction, - ISessionErrorAction, - ISessionInputRequestedAction, + SessionAction, + SessionErrorAction, + SessionInputRequestedAction, ITitleChangedAction, IToolCallCompleteAction, IToolCallReadyAction, @@ -33,11 +33,11 @@ import type { ITurnCompleteAction, IUsageAction, } from '../../common/state/sessionActions.js'; -import { SessionInputQuestionKind, ToolCallConfirmationReason, ToolResultContentType, type IMarkdownResponsePart, type IReasoningResponsePart, type ISessionInputRequest } from '../../common/state/sessionState.js'; +import { SessionInputQuestionKind, ToolCallConfirmationReason, ToolResultContentType, type MarkdownResponsePart, type ReasoningResponsePart, type SessionInputRequest } from '../../common/state/sessionState.js'; import { AgentEventMapper } from '../../node/agentEventMapper.js'; /** Helper: flatten the result of mapProgressEventToActions into an array. */ -function mapToArray(result: ISessionAction | ISessionAction[] | undefined): ISessionAction[] { +function mapToArray(result: SessionAction | SessionAction[] | undefined): SessionAction[] { if (!result) { return []; } @@ -78,7 +78,7 @@ suite('AgentEventMapper', () => { const second: IAgentDeltaEvent = { session, type: 'delta', messageId: 'msg-1', content: 'world' }; const firstActions = mapToArray(mapper.mapProgressEventToActions(first, session.toString(), turnId)); - const partId = ((firstActions[0] as IResponsePartAction).part as IMarkdownResponsePart).id; + const partId = ((firstActions[0] as IResponsePartAction).part as MarkdownResponsePart).id; const secondActions = mapToArray(mapper.mapProgressEventToActions(second, session.toString(), turnId)); assert.strictEqual(secondActions.length, 1); @@ -167,7 +167,7 @@ suite('AgentEventMapper', () => { const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); assert.strictEqual(actions.length, 1); - const errorAction = actions[0] as ISessionErrorAction; + const errorAction = actions[0] as SessionErrorAction; assert.strictEqual(errorAction.type, 'session/error'); assert.strictEqual(errorAction.error.errorType, 'runtime'); assert.strictEqual(errorAction.error.message, 'Something went wrong'); @@ -228,7 +228,7 @@ suite('AgentEventMapper', () => { const second: IAgentReasoningEvent = { session, type: 'reasoning', content: ' more thoughts' }; const firstActions = mapToArray(mapper.mapProgressEventToActions(first, session.toString(), turnId)); - const partId = ((firstActions[0] as IResponsePartAction).part as IReasoningResponsePart).id; + const partId = ((firstActions[0] as IResponsePartAction).part as ReasoningResponsePart).id; const secondActions = mapToArray(mapper.mapProgressEventToActions(second, session.toString(), turnId)); assert.strictEqual(secondActions.length, 1); @@ -317,7 +317,7 @@ suite('AgentEventMapper', () => { }); test('user_input_request event maps to session/inputRequested action', () => { - const request: ISessionInputRequest = { + const request: SessionInputRequest = { id: 'req-1', message: 'What is your name?', questions: [{ @@ -335,7 +335,7 @@ suite('AgentEventMapper', () => { const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); assert.strictEqual(actions.length, 1); - const action = actions[0] as ISessionInputRequestedAction; + const action = actions[0] as SessionInputRequestedAction; assert.strictEqual(action.type, 'session/inputRequested'); assert.strictEqual(action.session, session.toString()); assert.strictEqual(action.request, request); diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 7d66859f3a610..cf5d8aab4c183 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -9,9 +9,9 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { NullLogService } from '../../../log/common/log.js'; -import { ActionType, NotificationType, type IActionEnvelope, type INotification } from '../../common/state/sessionActions.js'; -import { ISessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildSubagentSessionUri, isSubagentSession, parseSubagentSessionUri, type IMarkdownResponsePart, type ISessionState } from '../../common/state/sessionState.js'; -import { type ISessionSummaryChangedNotification } from '../../common/state/protocol/notifications.js'; +import { ActionType, NotificationType, type ActionEnvelope, type INotification } from '../../common/state/sessionActions.js'; +import { SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildSubagentSessionUri, isSubagentSession, parseSubagentSessionUri, type MarkdownResponsePart, type SessionState } from '../../common/state/sessionState.js'; +import { type SessionSummaryChangedNotification } from '../../common/state/protocol/notifications.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; suite('AgentHostStateManager', () => { @@ -20,7 +20,7 @@ suite('AgentHostStateManager', () => { let manager: AgentHostStateManager; const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString(); - function makeSessionSummary(resource?: string): ISessionSummary { + function makeSessionSummary(resource?: string): SessionSummary { return { resource: resource ?? sessionUri, provider: 'copilot', @@ -69,13 +69,13 @@ suite('AgentHostStateManager', () => { const snapshot = manager.getSnapshot(sessionUri); assert.ok(snapshot); assert.strictEqual(snapshot.resource.toString(), sessionUri.toString()); - assert.strictEqual((snapshot.state as ISessionState).lifecycle, SessionLifecycle.Creating); + assert.strictEqual((snapshot.state as SessionState).lifecycle, SessionLifecycle.Creating); }); test('dispatchServerAction applies action and emits envelope', () => { manager.createSession(makeSessionSummary()); - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); manager.dispatchServerAction({ @@ -96,7 +96,7 @@ suite('AgentHostStateManager', () => { test('serverSeq increments monotonically', () => { manager.createSession(makeSessionSummary()); - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); @@ -111,7 +111,7 @@ suite('AgentHostStateManager', () => { test('dispatchClientAction includes origin in envelope', () => { manager.createSession(makeSessionSummary()); - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); const origin = { clientId: 'renderer-1', clientSeq: 42 }; @@ -187,7 +187,7 @@ suite('AgentHostStateManager', () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); manager.dispatchServerAction({ @@ -213,7 +213,7 @@ suite('AgentHostStateManager', () => { userMessage: { text: 'hello' }, }); - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); manager.dispatchServerAction({ @@ -269,7 +269,7 @@ suite('AgentHostStateManager', () => { { id: 'turn-1', userMessage: { text: 'hello' }, - responseParts: [{ kind: ResponsePartKind.Markdown, id: 'p1', content: 'world' } satisfies IMarkdownResponsePart], + responseParts: [{ kind: ResponsePartKind.Markdown, id: 'p1', content: 'world' } satisfies MarkdownResponsePart], usage: undefined, state: TurnState.Complete, }, @@ -279,7 +279,7 @@ suite('AgentHostStateManager', () => { assert.strictEqual(state.lifecycle, SessionLifecycle.Ready); assert.strictEqual(state.turns.length, 1); assert.strictEqual(state.turns[0].userMessage.text, 'hello'); - assert.strictEqual((state.turns[0].responseParts[0] as IMarkdownResponsePart).content, 'world'); + assert.strictEqual((state.turns[0].responseParts[0] as MarkdownResponsePart).content, 'world'); }); test('restoreSession returns existing state for duplicate session', () => { @@ -317,7 +317,7 @@ suite('AgentHostStateManager', () => { const changed = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged); assert.strictEqual(changed.length, 1); - const notification = changed[0] as ISessionSummaryChangedNotification; + const notification = changed[0] as SessionSummaryChangedNotification; assert.strictEqual(notification.session, sessionUri); assert.strictEqual(notification.changes.title, 'New Title'); assert.strictEqual(notification.changes.status, undefined, 'unchanged fields should be omitted'); @@ -339,7 +339,7 @@ suite('AgentHostStateManager', () => { const changed = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged); assert.strictEqual(changed.length, 1, 'should coalesce into one notification'); - assert.strictEqual((changed[0] as ISessionSummaryChangedNotification).changes.title, 'Second'); + assert.strictEqual((changed[0] as SessionSummaryChangedNotification).changes.title, 'Second'); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts index 2eaaec89d7557..3df40c8ce0be8 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts @@ -6,8 +6,8 @@ import assert from 'assert'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { ActionType, IStateAction } from '../../common/state/protocol/actions.js'; -import { ITerminalContentPart } from '../../common/state/protocol/state.js'; +import { ActionType, StateAction } from '../../common/state/protocol/actions.js'; +import { TerminalContentPart } from '../../common/state/protocol/state.js'; import { Osc633Event, Osc633EventType, Osc633Parser } from '../../node/osc633Parser.js'; /** @@ -36,8 +36,8 @@ interface ITestCommandTracker { * that can be tested without node-pty or a real AgentHostStateManager. */ class TestTerminalDataHandler { - readonly dispatched: IStateAction[] = []; - content: ITerminalContentPart[] = []; + readonly dispatched: StateAction[] = []; + content: TerminalContentPart[] = []; cwd = '/home/user'; constructor( diff --git a/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts b/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts index 497d945e68430..7ac15fc57b212 100644 --- a/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts @@ -13,7 +13,7 @@ import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; import { AGENT_CLIENT_SCHEME, toAgentClientUri } from '../../common/agentClientUri.js'; -import { CustomizationStatus, type ICustomizationRef, type ISessionCustomization } from '../../common/state/sessionState.js'; +import { CustomizationStatus, type CustomizationRef, type SessionCustomization } from '../../common/state/sessionState.js'; import { AgentPluginManager } from '../../node/agentPluginManager.js'; suite('AgentPluginManager', () => { @@ -37,7 +37,7 @@ suite('AgentPluginManager', () => { return URI.from({ scheme: Schemas.inMemory, path: `/plugins/${name}` }).toString(); } - function makeRef(name: string, nonce?: string): ICustomizationRef { + function makeRef(name: string, nonce?: string): CustomizationRef { return { uri: pluginUri(name), displayName: `Plugin ${name}`, nonce }; } @@ -91,7 +91,7 @@ suite('AgentPluginManager', () => { test('fires progress callback with loading, then loaded', async () => { await seedPluginDir('prog', { 'index.js': 'content' }); - const progressCalls: ISessionCustomization[][] = []; + const progressCalls: SessionCustomization[][] = []; await manager.syncCustomizations('test-client', [makeRef('prog', 'n1')], statuses => { progressCalls.push(statuses); }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 6d4c6d43689a6..c847ffbf14df2 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -18,8 +18,8 @@ import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesy import { AgentSession } from '../../common/agentService.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; -import { ActionType, IActionEnvelope } from '../../common/state/sessionActions.js'; -import { ISessionActiveClient, ResponsePartKind, SessionLifecycle, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type IMarkdownResponsePart, type IToolCallCompletedState, type IToolCallResponsePart } from '../../common/state/sessionState.js'; +import { ActionType, ActionEnvelope } from '../../common/state/sessionActions.js'; +import { SessionActiveClient, ResponsePartKind, SessionLifecycle, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type MarkdownResponsePart, type ToolCallCompletedState, type ToolCallResponsePart } from '../../common/state/sessionState.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; import { MockAgent } from './mockAgent.js'; @@ -30,7 +30,7 @@ import { createSessionDataService } from '../common/sessionTestHelpers.js'; * Loads a JSONL fixture of raw Copilot SDK events, runs them through * {@link mapSessionEvents}, and returns the result suitable for setting * on {@link MockAgent.sessionMessages}. This tests the full pipeline: - * SDK events → mapSessionEvents → _buildTurnsFromMessages → ITurn[]. + * SDK events → mapSessionEvents → _buildTurnsFromMessages → Turn[]. * * Fixture files live in `test-cases/` and are sanitized copies of real * `events.jsonl` files from `~/.copilot/session-state/`. @@ -108,7 +108,7 @@ suite('AgentService (node dispatcher)', () => { 'test-client', 1, ); - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(service.onDidAction(e => envelopes.push(e))); copilotAgent.fireProgress({ session, type: 'delta', messageId: 'msg-1', content: 'hello' }); @@ -254,10 +254,10 @@ suite('AgentService (node dispatcher)', () => { test('seeds activeClient into the initial session state when provided', async () => { service.registerProvider(copilotAgent); - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(service.onDidAction(env => envelopes.push(env))); - const activeClient: ISessionActiveClient = { + const activeClient: SessionActiveClient = { clientId: 'client-eager', tools: [{ name: 't1', description: 'd', inputSchema: { type: 'object' } }], customizations: [{ uri: 'file:///plugin-a', displayName: 'A' }], @@ -342,7 +342,7 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(state!.lifecycle, SessionLifecycle.Ready); assert.strictEqual(state!.turns.length, 1); assert.strictEqual(state!.turns[0].userMessage.text, 'Hello'); - const mdPart = state!.turns[0].responseParts.find((p): p is IMarkdownResponsePart => p.kind === ResponsePartKind.Markdown); + const mdPart = state!.turns[0].responseParts.find((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown); assert.ok(mdPart); assert.strictEqual(mdPart.content, 'Hi there!'); assert.strictEqual(state!.turns[0].state, TurnState.Complete); @@ -367,9 +367,9 @@ suite('AgentService (node dispatcher)', () => { const state = service.stateManager.getSessionState(sessionResource.toString()); assert.ok(state); const turn = state!.turns[0]; - const toolCallParts = turn.responseParts.filter((p): p is IToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); + const toolCallParts = turn.responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); assert.strictEqual(toolCallParts.length, 1); - const tc = toolCallParts[0].toolCall as IToolCallCompletedState; + const tc = toolCallParts[0].toolCall as ToolCallCompletedState; assert.strictEqual(tc.status, ToolCallStatus.Completed); assert.strictEqual(tc.toolCallId, 'tc-1'); assert.strictEqual(tc.confirmed, ToolCallConfirmationReason.NotNeeded); @@ -439,11 +439,11 @@ suite('AgentService (node dispatcher)', () => { // The parent turn should only have the parent tool call — inner // tool calls are excluded from the parent and belong to the // child subagent session instead. - const toolCallParts = turn.responseParts.filter((p): p is IToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); + const toolCallParts = turn.responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); assert.strictEqual(toolCallParts.length, 1, `Expected 1 tool call (parent only) but got ${toolCallParts.length}`); // Parent subagent tool call - const parentTc = toolCallParts[0].toolCall as IToolCallCompletedState; + const parentTc = toolCallParts[0].toolCall as ToolCallCompletedState; assert.strictEqual(parentTc.toolCallId, 'tc-sub'); assert.strictEqual(parentTc.status, ToolCallStatus.Completed); assert.strictEqual(parentTc._meta?.toolKind, 'subagent'); @@ -462,13 +462,13 @@ suite('AgentService (node dispatcher)', () => { assert.ok(snapshot?.state, 'Child session snapshot should exist'); assert.ok(childState, 'Child session state should exist'); assert.strictEqual(childState!.turns.length, 1, 'Child session should have 1 turn'); - const childToolParts = childState!.turns[0].responseParts.filter((p): p is IToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); + const childToolParts = childState!.turns[0].responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); assert.strictEqual(childToolParts.length, 2, `Child session should have 2 inner tool calls but got ${childToolParts.length}`); assert.ok(childToolParts.some(p => p.toolCall.toolCallId === 'tc-inner-1'), 'Should have tc-inner-1'); assert.ok(childToolParts.some(p => p.toolCall.toolCallId === 'tc-inner-2'), 'Should have tc-inner-2'); // The turn should also have the final markdown - const mdParts = turn.responseParts.filter((p): p is IMarkdownResponsePart => p.kind === ResponsePartKind.Markdown); + const mdParts = turn.responseParts.filter((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown); assert.ok(mdParts.some(p => p.content.includes('3 issues')), 'Should have the final markdown response'); }); @@ -490,7 +490,7 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(state!.turns[0].state, TurnState.Complete); // Should have the parent subagent tool call with subagent content - const toolCallParts = state!.turns[0].responseParts.filter((p): p is IToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); + const toolCallParts = state!.turns[0].responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); const parentTc = toolCallParts.find(p => p.toolCall.toolName === 'task'); assert.ok(parentTc, 'Should have a task tool call'); assert.strictEqual(parentTc!.toolCall._meta?.toolKind, 'subagent'); @@ -508,11 +508,11 @@ suite('AgentService (node dispatcher)', () => { const childState = service.stateManager.getSessionState(childSessionUri); assert.ok(childState, 'Child session state should exist'); assert.strictEqual(childState!.turns.length, 1, 'Child session should have 1 turn'); - const childToolParts = childState!.turns[0].responseParts.filter((p): p is IToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); + const childToolParts = childState!.turns[0].responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); assert.ok(childToolParts.length > 0, `Child session should have inner tool calls but got ${childToolParts.length}`); // Should have the final markdown - const mdParts = state!.turns[0].responseParts.filter((p): p is IMarkdownResponsePart => p.kind === ResponsePartKind.Markdown); + const mdParts = state!.turns[0].responseParts.filter((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown); assert.ok(mdParts.length > 0, 'Should have markdown content'); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 524909a489183..6ab9afc7f4fbe 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -17,7 +17,7 @@ import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesy import { NullLogService } from '../../../log/common/log.js'; import { AgentSession, IAgent } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; -import { ActionType, IActionEnvelope, ISessionAction } from '../../common/state/sessionActions.js'; +import { ActionType, ActionEnvelope, SessionAction } from '../../common/state/sessionActions.js'; import { buildSubagentSessionUri, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType } from '../../common/state/sessionState.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; @@ -93,7 +93,7 @@ suite('AgentSideEffects', () => { test('calls sendMessage on the agent', async () => { setupSession(); - const action: ISessionAction = { + const action: SessionAction = { type: ActionType.SessionTurnStarted, session: sessionUri.toString(), turnId: 'turn-1', @@ -116,7 +116,7 @@ suite('AgentSideEffects', () => { sessionDataService: {} as ISessionDataService, }, new NullLogService())); - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); noAgentSideEffects.handleAction({ @@ -151,7 +151,7 @@ suite('AgentSideEffects', () => { test('dispatches titleChanged with user message on first turn', () => { setupDefaultSession(); - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); sideEffects.handleAction({ @@ -171,7 +171,7 @@ suite('AgentSideEffects', () => { test('does not dispatch titleChanged when message is whitespace', () => { setupDefaultSession(); - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); sideEffects.handleAction({ @@ -188,7 +188,7 @@ suite('AgentSideEffects', () => { test('normalizes whitespace and truncates long messages', () => { setupDefaultSession(); - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); const longMessage = 'Fix the bug\nin the login\tpage please ' + 'a'.repeat(250); @@ -220,7 +220,7 @@ suite('AgentSideEffects', () => { turnId: 'turn-1', }); - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); sideEffects.handleAction({ @@ -247,7 +247,7 @@ suite('AgentSideEffects', () => { }); stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri.toString() }); - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); sideEffects.handleAction({ @@ -304,7 +304,7 @@ suite('AgentSideEffects', () => { setupSession(); startTurn('turn-1'); - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); disposables.add(sideEffects.registerProgressListener(agent)); @@ -318,7 +318,7 @@ suite('AgentSideEffects', () => { setupSession(); startTurn('turn-1'); - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); const listener = sideEffects.registerProgressListener(agent); @@ -351,7 +351,7 @@ suite('AgentSideEffects', () => { }); test('model observable update publishes models', async () => { - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); const envelope = Event.toPromise(Event.filter(stateManager.onDidEmitEnvelope, e => { @@ -378,7 +378,7 @@ suite('AgentSideEffects', () => { }); test('unchanged model observable update does not dispatch unchanged agent infos', async () => { - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); const models = [{ provider: 'mock' as const, id: 'mock-model', name: 'mock Model', maxContextWindow: 128000, supportsVision: false }]; @@ -521,7 +521,7 @@ suite('AgentSideEffects', () => { // Message should NOT be consumed yet (turn is active) assert.strictEqual(agent.sendMessageCalls.length, 0); - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); // Fire idle → turn completes → queued message should be consumed @@ -546,7 +546,7 @@ suite('AgentSideEffects', () => { setupSession(); startTurn('turn-1'); - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); const setAction = { @@ -574,7 +574,7 @@ suite('AgentSideEffects', () => { setupSession(); disposables.add(sideEffects.registerProgressListener(agent)); - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); const action = { @@ -621,10 +621,10 @@ suite('AgentSideEffects', () => { test('calls setClientCustomizations and dispatches customizationsChanged', async () => { setupSession(); - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - const action: ISessionAction = { + const action: SessionAction = { type: ActionType.SessionActiveClientChanged, session: sessionUri.toString(), activeClient: { @@ -657,10 +657,10 @@ suite('AgentSideEffects', () => { test('skips when activeClient has no customizations', () => { setupSession(); - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - const action: ISessionAction = { + const action: SessionAction = { type: ActionType.SessionActiveClientChanged, session: sessionUri.toString(), activeClient: { @@ -679,7 +679,7 @@ suite('AgentSideEffects', () => { test('skips when activeClient is null', () => { setupSession(); - const action: ISessionAction = { + const action: SessionAction = { type: ActionType.SessionActiveClientChanged, session: sessionUri.toString(), activeClient: null, @@ -697,7 +697,7 @@ suite('AgentSideEffects', () => { test('calls setCustomizationEnabled on the agent', () => { setupSession(); - const action: ISessionAction = { + const action: SessionAction = { type: ActionType.SessionCustomizationToggled, session: sessionUri.toString(), uri: 'file:///plugin-a', @@ -747,7 +747,7 @@ suite('AgentSideEffects', () => { toolCallId: 'tc-conf-1', approved: true, confirmed: 'user-action' as const, - } as ISessionAction); + } as SessionAction); assert.deepStrictEqual(agent.respondToPermissionCalls, [ { requestId: 'tc-conf-1', approved: true }, @@ -775,7 +775,7 @@ suite('AgentSideEffects', () => { toolCallId: 'tc-deny-1', approved: false, reason: 'denied' as const, - } as ISessionAction); + } as SessionAction); assert.deepStrictEqual(agent.respondToPermissionCalls, [ { requestId: 'tc-deny-1', approved: false }, @@ -970,7 +970,7 @@ suite('AgentSideEffects', () => { startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); agent.fireProgress({ @@ -1119,7 +1119,7 @@ suite('AgentSideEffects', () => { startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); - const envelopes: IActionEnvelope[] = []; + const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); agent.fireProgress({ @@ -1672,4 +1672,177 @@ suite('AgentSideEffects', () => { ]); }); }); + + // ---- Session permissions ------------------------------------------------ + + suite('session permissions', () => { + + test('tool_ready action includes confirmation options when confirmation is needed', () => { + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ + session: sessionUri, + type: 'tool_start', + toolCallId: 'tc-perm-1', + toolName: 'CustomTool', + displayName: 'Custom Tool', + invocationMessage: 'Running custom tool', + }); + + agent.fireProgress({ + session: sessionUri, + type: 'tool_ready', + toolCallId: 'tc-perm-1', + invocationMessage: 'Run custom tool', + confirmationTitle: 'Run custom tool', + permissionKind: 'custom-tool', + }); + + const state = stateManager.getSessionState(sessionUri.toString()); + const tc = state!.activeTurn!.responseParts.find( + rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-perm-1' + ); + assert.ok(tc && tc.kind === ResponsePartKind.ToolCall, 'tool call should exist'); + assert.strictEqual(tc.toolCall.status, ToolCallStatus.PendingConfirmation); + assert.ok(Array.isArray(tc.toolCall.options), 'options should be an array'); + assert.deepStrictEqual(tc.toolCall.options!.map(o => o.id), ['allow-session', 'allow-once', 'skip']); + }); + + test('SessionToolCallConfirmed with allow-session adds tool to session permissions', () => { + setupSession(); + const state = stateManager.getSessionState(sessionUri.toString()); + if (state) { + state.config = { + schema: { type: 'object', properties: {} }, + values: {}, + }; + } + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ + session: sessionUri, + type: 'tool_start', + toolCallId: 'tc-perm-2', + toolName: 'CustomTool', + displayName: 'Custom Tool', + invocationMessage: 'Running custom tool', + }); + + agent.fireProgress({ + session: sessionUri, + type: 'tool_ready', + toolCallId: 'tc-perm-2', + invocationMessage: 'Run custom tool', + confirmationTitle: 'Run custom tool', + permissionKind: 'custom-tool', + }); + + sideEffects.handleAction({ + type: ActionType.SessionToolCallConfirmed, + session: sessionUri.toString(), + turnId: 'turn-1', + toolCallId: 'tc-perm-2', + approved: true, + confirmed: 'user-action' as const, + selectedOptionId: 'allow-session', + } as SessionAction); + + const updatedState = stateManager.getSessionState(sessionUri.toString()); + assert.deepStrictEqual( + updatedState!.config!.values.permissions, + { allow: ['CustomTool'], deny: [] }, + ); + }); + + test('subsequent tool_ready for same tool is auto-approved after allow-session permission', () => { + setupSession(); + const state = stateManager.getSessionState(sessionUri.toString()); + if (state) { + state.config = { + schema: { type: 'object', properties: {} }, + values: { permissions: { allow: ['CustomTool'], deny: [] } }, + }; + } + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ + session: sessionUri, + type: 'tool_start', + toolCallId: 'tc-perm-3', + toolName: 'CustomTool', + displayName: 'Custom Tool', + invocationMessage: 'Running custom tool', + }); + + agent.fireProgress({ + session: sessionUri, + type: 'tool_ready', + toolCallId: 'tc-perm-3', + invocationMessage: 'Run custom tool', + confirmationTitle: 'Run custom tool', + permissionKind: 'custom-tool', + }); + + assert.deepStrictEqual(agent.respondToPermissionCalls, [ + { requestId: 'tc-perm-3', approved: true }, + ]); + }); + + test('subagent tool calls inherit parent session permissions', () => { + setupSession(); + const state = stateManager.getSessionState(sessionUri.toString()); + if (state) { + state.config = { + schema: { type: 'object', properties: {} }, + values: { permissions: { allow: ['CustomTool'], deny: [] } }, + }; + } + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ + session: sessionUri, + type: 'tool_start', + toolCallId: 'tc-parent', + toolName: 'task', + displayName: 'Task', + invocationMessage: 'Delegating...', + }); + agent.fireProgress({ + session: sessionUri, + type: 'subagent_started', + toolCallId: 'tc-parent', + agentName: 'helper', + agentDisplayName: 'Helper', + agentDescription: 'Helps', + }); + + agent.fireProgress({ + session: sessionUri, + type: 'tool_start', + toolCallId: 'inner-perm-1', + toolName: 'CustomTool', + displayName: 'Custom Tool', + invocationMessage: 'Running custom tool', + parentToolCallId: 'tc-parent', + }); + + agent.fireProgress({ + session: sessionUri, + type: 'tool_ready', + toolCallId: 'inner-perm-1', + invocationMessage: 'Run custom tool', + confirmationTitle: 'Run custom tool', + permissionKind: 'custom-tool', + }); + + assert.deepStrictEqual(agent.respondToPermissionCalls, [ + { requestId: 'inner-perm-1', approved: true }, + ]); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 72578eed5d793..f4f73c5a9d936 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -21,7 +21,7 @@ import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPlu import { AgentSession, type IAgentDeltaEvent, type IAgentMessageEvent, type IAgentProgressEvent, type IAgentSessionMetadata, type IAgentToolStartEvent } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; -import { ISessionCustomization, ICustomizationRef } from '../../common/state/sessionState.js'; +import { SessionCustomization, CustomizationRef } from '../../common/state/sessionState.js'; import { IAgentHostGitService } from '../../node/agentHostGitService.js'; import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; import { CopilotAgent, getCopilotWorktreeBranchName, getCopilotWorktreeName, getCopilotWorktreesRoot, type ICopilotClient } from '../../node/copilot/copilotAgent.js'; @@ -34,7 +34,7 @@ import { createNullSessionDataService } from '../common/sessionTestHelpers.js'; class TestAgentPluginManager implements IAgentPluginManager { declare readonly _serviceBrand: undefined; - async syncCustomizations(_clientId: string, _customizations: ICustomizationRef[], _progress?: (status: ISessionCustomization[]) => void): Promise { + async syncCustomizations(_clientId: string, _customizations: CustomizationRef[], _progress?: (status: SessionCustomization[]) => void): Promise { return []; } } @@ -390,9 +390,9 @@ suite('CopilotAgent', () => { suite('createSession activeClient eager-claim', () => { class SpyingPluginManager extends TestAgentPluginManager { - public readonly calls: { clientId: string; customizations: ICustomizationRef[] }[] = []; + public readonly calls: { clientId: string; customizations: CustomizationRef[] }[] = []; - override async syncCustomizations(clientId: string, customizations: ICustomizationRef[], _progress?: (status: ISessionCustomization[]) => void): Promise { + override async syncCustomizations(clientId: string, customizations: CustomizationRef[], _progress?: (status: SessionCustomization[]) => void): Promise { this.calls.push({ clientId, customizations: [...customizations] }); return []; } @@ -411,7 +411,7 @@ suite('CopilotAgent', () => { try { await agent.authenticate('https://api.github.com', 'token'); - const customizations: ICustomizationRef[] = [{ uri: 'file:///plugin-a', displayName: 'Plugin A' }]; + const customizations: CustomizationRef[] = [{ uri: 'file:///plugin-a', displayName: 'Plugin A' }]; await assert.rejects( agent.createSession({ session: AgentSession.uri('copilotcli', 'test-session'), diff --git a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts index ef56bf3e74f22..698ab70dcdb7e 100644 --- a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts @@ -11,17 +11,17 @@ import { IInstantiationService } from '../../../instantiation/common/instantiati import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; -import type { ICreateTerminalParams } from '../../common/state/protocol/commands.js'; -import type { ITerminalClaim, ITerminalInfo } from '../../common/state/protocol/state.js'; +import type { CreateTerminalParams } from '../../common/state/protocol/commands.js'; +import type { TerminalClaim, TerminalInfo } from '../../common/state/protocol/state.js'; import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; import { ShellManager, prefixForHistorySuppression } from '../../node/copilot/copilotShellTools.js'; class TestAgentHostTerminalManager implements IAgentHostTerminalManager { declare readonly _serviceBrand: undefined; - readonly created: { params: ICreateTerminalParams; options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean } }[] = []; + readonly created: { params: CreateTerminalParams; options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean } }[] = []; - async createTerminal(params: ICreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean }): Promise { + async createTerminal(params: CreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean }): Promise { this.created.push({ params, options }); } writeInput(): void { } @@ -30,12 +30,12 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager { onClaimChanged(): IDisposable { return Disposable.None; } onCommandFinished(): IDisposable { return Disposable.None; } getContent(): string | undefined { return undefined; } - getClaim(): ITerminalClaim | undefined { return undefined; } + getClaim(): TerminalClaim | undefined { return undefined; } hasTerminal(): boolean { return false; } getExitCode(): number | undefined { return undefined; } supportsCommandDetection(): boolean { return false; } disposeTerminal(): void { } - getTerminalInfos(): ITerminalInfo[] { return []; } + getTerminalInfos(): TerminalInfo[] { return []; } getTerminalState(): undefined { return undefined; } } diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 09bb59056122b..e89c5ecf0d6d1 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -10,9 +10,9 @@ import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/c import { URI } from '../../../../base/common/uri.js'; import { type ISyncedCustomization } from '../../common/agentPluginManager.js'; import { AgentSession, type AgentProvider, type IAgent, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentDescriptor, type IAgentMessageEvent, type IAgentModelInfo, type IAgentProgressEvent, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type IAgentSubagentStartedEvent, type IAgentToolCompleteEvent, type IAgentToolStartEvent } from '../../common/agentService.js'; -import { IProtectedResourceMetadata, type IModelSelection } from '../../common/state/protocol/state.js'; -import type { IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; -import { CustomizationStatus, ToolResultContentType, type ICustomizationRef, type IPendingMessage, type IToolCallResult } from '../../common/state/sessionState.js'; +import { ProtectedResourceMetadata, type ModelSelection } from '../../common/state/protocol/state.js'; +import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; +import { CustomizationStatus, ToolResultContentType, type CustomizationRef, type PendingMessage, type ToolCallResult } from '../../common/state/sessionState.js'; /** Well-known auto-generated title used by the 'with-title' prompt. */ export const MOCK_AUTO_TITLE = 'Automatically generated title'; @@ -36,16 +36,16 @@ export class MockAgent implements IAgent { readonly sendMessageCalls: { session: URI; prompt: string }[] = []; - readonly setPendingMessagesCalls: { session: URI; steeringMessage: IPendingMessage | undefined; queuedMessages: readonly IPendingMessage[] }[] = []; + readonly setPendingMessagesCalls: { session: URI; steeringMessage: PendingMessage | undefined; queuedMessages: readonly PendingMessage[] }[] = []; readonly disposeSessionCalls: URI[] = []; readonly abortSessionCalls: URI[] = []; readonly respondToPermissionCalls: { requestId: string; approved: boolean }[] = []; - readonly changeModelCalls: { session: URI; model: IModelSelection }[] = []; + readonly changeModelCalls: { session: URI; model: ModelSelection }[] = []; readonly authenticateCalls: { resource: string; token: string }[] = []; - readonly setClientCustomizationsCalls: { clientId: string; customizations: ICustomizationRef[] }[] = []; + readonly setClientCustomizationsCalls: { clientId: string; customizations: CustomizationRef[] }[] = []; readonly setCustomizationEnabledCalls: { uri: string; enabled: boolean }[] = []; /** Configurable return value for getCustomizations. */ - customizations: ICustomizationRef[] = []; + customizations: CustomizationRef[] = []; /** Configurable return value for getSessionMessages. */ sessionMessages: (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent | IAgentSubagentStartedEvent)[] = []; @@ -59,7 +59,7 @@ export class MockAgent implements IAgent { return { provider: this.id, displayName: `Agent ${this.id}`, description: `Test ${this.id} agent` }; } - getProtectedResources(): IProtectedResourceMetadata[] { + getProtectedResources(): ProtectedResourceMetadata[] { if (this.id === 'copilot') { return [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'], required: true }]; } @@ -84,11 +84,11 @@ export class MockAgent implements IAgent { return { session, project: mockProject(this.id), workingDirectory: this.resolvedWorkingDirectory }; } - async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { + async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { return { schema: { type: 'object', properties: {} }, values: params.config ?? {} }; } - async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { + async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { return { items: [] }; } @@ -96,7 +96,7 @@ export class MockAgent implements IAgent { this.sendMessageCalls.push({ session, prompt }); } - setPendingMessages(session: URI, steeringMessage: IPendingMessage | undefined, queuedMessages: readonly IPendingMessage[]): void { + setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, queuedMessages: readonly PendingMessage[]): void { this.setPendingMessagesCalls.push({ session, steeringMessage, queuedMessages }); } @@ -121,7 +121,7 @@ export class MockAgent implements IAgent { // no-op for tests } - async changeModel(session: URI, model: IModelSelection): Promise { + async changeModel(session: URI, model: ModelSelection): Promise { this.changeModelCalls.push({ session, model }); } @@ -130,11 +130,11 @@ export class MockAgent implements IAgent { return true; } - getCustomizations(): ICustomizationRef[] { + getCustomizations(): CustomizationRef[] { return this.customizations; } - async setClientCustomizations(clientId: string, customizations: ICustomizationRef[], progress?: (results: ISyncedCustomization[]) => void): Promise { + async setClientCustomizations(clientId: string, customizations: CustomizationRef[], progress?: (results: ISyncedCustomization[]) => void): Promise { this.setClientCustomizationsCalls.push({ clientId, customizations }); const results: ISyncedCustomization[] = customizations.map(c => ({ customization: { @@ -193,7 +193,7 @@ export class ScriptedMockAgent implements IAgent { private readonly _preExistingMessages: (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[] = [ { type: 'message', role: 'user', session: PRE_EXISTING_SESSION_URI, messageId: 'h-msg-1', content: 'What files are here?' }, { type: 'tool_start', session: PRE_EXISTING_SESSION_URI, toolCallId: 'h-tc-1', toolName: 'list_files', displayName: 'List Files', invocationMessage: 'Listing files...' }, - { type: 'tool_complete', session: PRE_EXISTING_SESSION_URI, toolCallId: 'h-tc-1', result: { pastTenseMessage: 'Listed files', content: [{ type: ToolResultContentType.Text, text: 'file1.ts\nfile2.ts' }], success: true } satisfies IToolCallResult }, + { type: 'tool_complete', session: PRE_EXISTING_SESSION_URI, toolCallId: 'h-tc-1', result: { pastTenseMessage: 'Listed files', content: [{ type: ToolResultContentType.Text, text: 'file1.ts\nfile2.ts' }], success: true } satisfies ToolCallResult }, { type: 'message', role: 'assistant', session: PRE_EXISTING_SESSION_URI, messageId: 'h-msg-2', content: 'Here are the files: file1.ts and file2.ts' }, ]; @@ -247,7 +247,7 @@ export class ScriptedMockAgent implements IAgent { return { session, project: mockProject(this.id) }; } - async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { + async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { const isolation = params.config?.isolation === 'folder' || params.config?.isolation === 'worktree' ? params.config.isolation : 'worktree'; const branch = isolation === 'worktree' && typeof params.config?.branch === 'string' ? params.config.branch : 'main'; return { @@ -278,7 +278,7 @@ export class ScriptedMockAgent implements IAgent { }; } - async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { + async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { if (params.property !== 'branch') { return { items: [] }; } @@ -577,7 +577,7 @@ export class ScriptedMockAgent implements IAgent { } } - setPendingMessages(session: URI, steeringMessage: IPendingMessage | undefined, _queuedMessages: readonly IPendingMessage[]): void { + setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[]): void { // When steering is set, consume it on the next tick if (steeringMessage) { timeout(20).then(() => { @@ -596,7 +596,7 @@ export class ScriptedMockAgent implements IAgent { setClientTools(): void { } - onClientToolCallComplete(session: URI, toolCallId: string, result: IToolCallResult): void { + onClientToolCallComplete(session: URI, toolCallId: string, result: ToolCallResult): void { // Fire tool_complete and resolve any pending callback. this._onDidSessionProgress.fire({ type: 'tool_complete', @@ -630,7 +630,7 @@ export class ScriptedMockAgent implements IAgent { } } - async changeModel(_session: URI, _model: IModelSelection): Promise { + async changeModel(_session: URI, _model: ModelSelection): Promise { // Mock agent doesn't track model state } diff --git a/src/vs/platform/agentHost/test/node/protocol/handshake.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/handshake.integrationTest.ts index f9c2003541905..05314e2ee0933 100644 --- a/src/vs/platform/agentHost/test/node/protocol/handshake.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/handshake.integrationTest.ts @@ -8,8 +8,8 @@ import { URI } from '../../../../../base/common/uri.js'; import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; import { JSON_RPC_PARSE_ERROR, - type IInitializeResult, - type IJsonRpcErrorResponse, + type InitializeResult, + type JsonRpcErrorResponse, } from '../../../common/state/sessionProtocol.js'; import { IServerHandle, nextSessionUri, startServer, TestProtocolClient } from './testHelpers.js'; @@ -40,7 +40,7 @@ suite('Protocol WebSocket — Handshake & Errors', function () { test('handshake returns initialize response with protocol version', async function () { this.timeout(5_000); - const result = await client.call('initialize', { + const result = await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-handshake', initialSubscriptions: [URI.from({ scheme: 'agenthost', path: '/root' }).toString()], @@ -60,7 +60,7 @@ suite('Protocol WebSocket — Handshake & Errors', function () { const responsePromise = raw.waitForRawMessage(); raw.sendRaw('this is not valid json{{{'); - const response = await responsePromise as IJsonRpcErrorResponse; + const response = await responsePromise as JsonRpcErrorResponse; assert.strictEqual(response.jsonrpc, '2.0'); assert.strictEqual(response.id, null); assert.strictEqual(response.error.code, JSON_RPC_PARSE_ERROR); diff --git a/src/vs/platform/agentHost/test/node/protocol/multiClient.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/multiClient.integrationTest.ts index 988af5838bc10..d2170523fa859 100644 --- a/src/vs/platform/agentHost/test/node/protocol/multiClient.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/multiClient.integrationTest.ts @@ -4,11 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { ISubscribeResult } from '../../../common/state/protocol/commands.js'; -import type { ISessionAddedNotification, ISessionRemovedNotification } from '../../../common/state/sessionActions.js'; +import { SubscribeResult } from '../../../common/state/protocol/commands.js'; +import type { SessionAddedNotification, SessionRemovedNotification } from '../../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; -import type { INotificationBroadcastParams, IReconnectResult } from '../../../common/state/sessionProtocol.js'; -import type { ISessionState } from '../../../common/state/sessionState.js'; +import type { INotificationBroadcastParams, ReconnectResult } from '../../../common/state/sessionProtocol.js'; +import type { SessionState } from '../../../common/state/sessionState.js'; import { createAndSubscribeSession, dispatchTurnStarted, @@ -67,8 +67,8 @@ suite('Protocol WebSocket — Multi-Client', function () { assert.ok(n1, 'client 1 should receive sessionAdded'); assert.ok(n2, 'client 2 should receive sessionAdded'); - const uri1 = ((n1.params as INotificationBroadcastParams).notification as ISessionAddedNotification).summary.resource; - const uri2 = ((n2.params as INotificationBroadcastParams).notification as ISessionAddedNotification).summary.resource; + const uri1 = ((n1.params as INotificationBroadcastParams).notification as SessionAddedNotification).summary.resource; + const uri2 = ((n2.params as INotificationBroadcastParams).notification as SessionAddedNotification).summary.resource; assert.strictEqual(uri1, uri2, 'both clients should see the same session URI'); client2.close(); @@ -95,8 +95,8 @@ suite('Protocol WebSocket — Multi-Client', function () { assert.ok(n1, 'client 1 should receive sessionRemoved'); assert.ok(n2, 'client 2 should receive sessionRemoved even without subscribing'); - const removed1 = (n1.params as INotificationBroadcastParams).notification as ISessionRemovedNotification; - const removed2 = (n2.params as INotificationBroadcastParams).notification as ISessionRemovedNotification; + const removed1 = (n1.params as INotificationBroadcastParams).notification as SessionRemovedNotification; + const removed2 = (n2.params as INotificationBroadcastParams).notification as SessionRemovedNotification; assert.strictEqual(removed1.session.toString(), sessionUri.toString()); assert.strictEqual(removed2.session.toString(), sessionUri.toString()); @@ -245,8 +245,8 @@ suite('Protocol WebSocket — Multi-Client', function () { await client2.connect(); await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-late-sub-2' }); - const result = await client2.call('subscribe', { resource: sessionUri }); - const state = result.snapshot.state as ISessionState; + const result = await client2.call('subscribe', { resource: sessionUri }); + const state = result.snapshot.state as SessionState; assert.ok(state.turns.length >= 1, `late subscriber should see completed turn, got ${state.turns.length}`); assert.strictEqual(state.turns[0].id, 'turn-late'); assert.strictEqual(state.turns[0].state, 'complete'); @@ -311,7 +311,7 @@ suite('Protocol WebSocket — Multi-Client', function () { const client2 = new TestProtocolClient(server.port); await client2.connect(); - const result = await client2.call('reconnect', { + const result = await client2.call('reconnect', { clientId: 'test-reconnect', lastSeenServerSeq: missedFromSeq, subscriptions: [sessionUri], diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts index 66fc211103fe1..e0619ef168d48 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts @@ -7,11 +7,11 @@ import assert from 'assert'; import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { URI } from '../../../../../base/common/uri.js'; -import type { IResolveSessionConfigResult, ISessionConfigCompletionsResult, ISubscribeResult } from '../../../common/state/protocol/commands.js'; -import { ActionType, type ISessionAddedNotification } from '../../../common/state/sessionActions.js'; +import type { ResolveSessionConfigResult, SessionConfigCompletionsResult, SubscribeResult } from '../../../common/state/protocol/commands.js'; +import { ActionType, type SessionAddedNotification } from '../../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; import type { INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js'; -import type { ISessionState } from '../../../common/state/sessionState.js'; +import type { SessionState } from '../../../common/state/sessionState.js'; import { getActionEnvelope, isActionNotification, @@ -50,7 +50,7 @@ suite('Protocol WebSocket - Session Config', function () { this.timeout(10_000); const workingDirectory = URI.file('/mock/workspace').toString(); - const initial = await client.call('resolveSessionConfig', { + const initial = await client.call('resolveSessionConfig', { provider: 'mock', workingDirectory, }); @@ -61,7 +61,7 @@ suite('Protocol WebSocket - Session Config', function () { assert.strictEqual(initial.schema.properties.branch.enumDynamic, true); assert.strictEqual(initial.schema.properties.branch.readOnly, false); - const folder = await client.call('resolveSessionConfig', { + const folder = await client.call('resolveSessionConfig', { provider: 'mock', workingDirectory, config: { isolation: 'folder', branch: 'feature/config' }, @@ -75,7 +75,7 @@ suite('Protocol WebSocket - Session Config', function () { test('sessionConfigCompletions returns dynamic branch matches', async function () { this.timeout(10_000); - const result = await client.call('sessionConfigCompletions', { + const result = await client.call('sessionConfigCompletions', { provider: 'mock', workingDirectory: URI.file('/mock/workspace').toString(), config: { isolation: 'worktree' }, @@ -102,11 +102,11 @@ suite('Protocol WebSocket - Session Config', function () { const notif = await client.waitForNotification(n => n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' ); - const notification = (notif.params as INotificationBroadcastParams).notification as ISessionAddedNotification; + const notification = (notif.params as INotificationBroadcastParams).notification as SessionAddedNotification; assert.strictEqual(Object.hasOwn(notification.summary, 'config'), false); - const snapshot = await client.call('subscribe', { resource: notification.summary.resource }); - const state = snapshot.snapshot.state as ISessionState; + const snapshot = await client.call('subscribe', { resource: notification.summary.resource }); + const state = snapshot.snapshot.state as SessionState; assert.deepStrictEqual(state.config?.values, config); assert.deepStrictEqual(Object.keys(state.config?.schema.properties ?? {}), ['isolation', 'branch']); }); @@ -123,8 +123,8 @@ suite('Protocol WebSocket - Session Config', function () { const notif = await client.waitForNotification(n => n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' ); - const session = ((notif.params as INotificationBroadcastParams).notification as ISessionAddedNotification).summary.resource; - await client.call('subscribe', { resource: session }); + const session = ((notif.params as INotificationBroadcastParams).notification as SessionAddedNotification).summary.resource; + await client.call('subscribe', { resource: session }); client.clearReceived(); client.notify('dispatchAction', { @@ -139,8 +139,8 @@ suite('Protocol WebSocket - Session Config', function () { const configChanged = await client.waitForNotification(n => isActionNotification(n, ActionType.SessionConfigChanged)); assert.strictEqual(getActionEnvelope(configChanged).action.type, ActionType.SessionConfigChanged); - const snapshot = await client.call('subscribe', { resource: session }); - const state = snapshot.snapshot.state as ISessionState; + const snapshot = await client.call('subscribe', { resource: session }); + const state = snapshot.snapshot.state as SessionState; assert.deepStrictEqual(state.config?.values, { isolation: 'folder', branch: 'release' }); }); }); @@ -186,9 +186,9 @@ suite('Protocol WebSocket - Session Config persistence across restarts', functio ); // The mock agent assigns its own URI rather than honoring the // requested one, so capture the real URI from the notification. - sessionUri = ((addedNotif.params as INotificationBroadcastParams).notification as ISessionAddedNotification).summary.resource; + sessionUri = ((addedNotif.params as INotificationBroadcastParams).notification as SessionAddedNotification).summary.resource; - await client1.call('subscribe', { resource: sessionUri }); + await client1.call('subscribe', { resource: sessionUri }); client1.notify('dispatchAction', { clientSeq: 1, @@ -229,8 +229,8 @@ suite('Protocol WebSocket - Session Config persistence across restarts', functio // Subscribing triggers the restore-on-subscribe path on the server, // which reads `configValues` from the per-session DB and overlays // them on the freshly-resolved schema. - const snapshot = await client2.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as ISessionState; + const snapshot = await client2.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as SessionState; assert.ok(state.config, 'restored session should have state.config populated'); // Schema is re-resolved by the provider (worktree-mode mock returns diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts index a5c502c6243eb..5f0cbbd31be3c 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts @@ -5,11 +5,11 @@ import assert from 'assert'; import { timeout } from '../../../../../base/common/async.js'; -import { ISubscribeResult } from '../../../common/state/protocol/commands.js'; -import type { IModelChangedAction, IResponsePartAction, ISessionAddedNotification, ITitleChangedAction } from '../../../common/state/sessionActions.js'; +import { SubscribeResult } from '../../../common/state/protocol/commands.js'; +import type { IModelChangedAction, IResponsePartAction, SessionAddedNotification, ITitleChangedAction } from '../../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; -import type { IListSessionsResult, INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js'; -import { PendingMessageKind, ResponsePartKind, type ISessionState } from '../../../common/state/sessionState.js'; +import type { ListSessionsResult, INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js'; +import { PendingMessageKind, ResponsePartKind, type SessionState } from '../../../common/state/sessionState.js'; import { MOCK_AUTO_TITLE } from '../mockAgent.js'; import { createAndSubscribeSession, @@ -66,8 +66,8 @@ suite('Protocol WebSocket — Session Features', function () { const titleAction = getActionEnvelope(titleNotif).action as ITitleChangedAction; assert.strictEqual(titleAction.title, 'My Custom Title'); - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as ISessionState; + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as SessionState; assert.strictEqual(state.summary.title, 'My Custom Title'); }); @@ -91,8 +91,8 @@ suite('Protocol WebSocket — Session Features', function () { await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as ISessionState; + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as SessionState; assert.strictEqual(state.summary.title, MOCK_AUTO_TITLE); }); @@ -102,8 +102,8 @@ suite('Protocol WebSocket — Session Features', function () { const sessionUri = await createAndSubscribeSession(client, 'test-immediate-title'); // Verify the session starts with the default placeholder title - const before = await client.call('subscribe', { resource: sessionUri }); - assert.strictEqual((before.snapshot.state as ISessionState).summary.title, ''); + const before = await client.call('subscribe', { resource: sessionUri }); + assert.strictEqual((before.snapshot.state as SessionState).summary.title, ''); // Send first turn — side effects should dispatch an immediate titleChanged // with the user's message text before the agent produces its own title. @@ -115,7 +115,7 @@ suite('Protocol WebSocket — Session Features', function () { assert.strictEqual(titleAction.title, 'Fix the login bug'); // listSessions should also reflect the updated title - const result = await client.call('listSessions'); + const result = await client.call('listSessions'); const session = result.items.find(s => s.resource === sessionUri); assert.ok(session, 'session should appear in listSessions'); assert.strictEqual(session.title, 'Fix the login bug'); @@ -140,7 +140,7 @@ suite('Protocol WebSocket — Session Features', function () { // Poll listSessions until the persisted title appears (async DB write) let session: { title: string } | undefined; for (let i = 0; i < 20; i++) { - const result = await client.call('listSessions'); + const result = await client.call('listSessions'); session = result.items.find(s => s.resource === sessionUri); if (session?.title === 'Persisted Title') { break; @@ -164,15 +164,15 @@ suite('Protocol WebSocket — Session Features', function () { const addedNotif = await client.waitForNotification(n => n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' ); - const addedSession = (addedNotif.params as INotificationBroadcastParams).notification as ISessionAddedNotification; + const addedSession = (addedNotif.params as INotificationBroadcastParams).notification as SessionAddedNotification; assert.deepStrictEqual(addedSession.summary.model, { id: 'mock-model' }); const createdSessionUri = addedSession.summary.resource; - const initialSnapshot = await client.call('subscribe', { resource: createdSessionUri }); - const initialState = initialSnapshot.snapshot.state as ISessionState; + const initialSnapshot = await client.call('subscribe', { resource: createdSessionUri }); + const initialState = initialSnapshot.snapshot.state as SessionState; assert.deepStrictEqual(initialState.summary.model, { id: 'mock-model' }); - const initialList = await client.call('listSessions'); + const initialList = await client.call('listSessions'); assert.deepStrictEqual(initialList.items.find(s => s.resource === createdSessionUri)?.model, { id: 'mock-model' }); client.notify('dispatchAction', { @@ -188,11 +188,11 @@ suite('Protocol WebSocket — Session Features', function () { const modelAction = getActionEnvelope(modelNotif).action as IModelChangedAction; assert.deepStrictEqual(modelAction.model, { id: 'mock-model-2' }); - const updatedSnapshot = await client.call('subscribe', { resource: createdSessionUri }); - const updatedState = updatedSnapshot.snapshot.state as ISessionState; + const updatedSnapshot = await client.call('subscribe', { resource: createdSessionUri }); + const updatedState = updatedSnapshot.snapshot.state as SessionState; assert.deepStrictEqual(updatedState.summary.model, { id: 'mock-model-2' }); - const updatedList = await client.call('listSessions'); + const updatedList = await client.call('listSessions'); assert.deepStrictEqual(updatedList.items.find(s => s.resource === createdSessionUri)?.model, { id: 'mock-model-2' }); }); @@ -262,8 +262,8 @@ suite('Protocol WebSocket — Session Features', function () { await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); // Verify the turn was created from the queued message - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as ISessionState; + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as SessionState; assert.ok(state.turns.length >= 1); assert.strictEqual(state.turns[state.turns.length - 1].userMessage.text, 'hello'); // Queue should be empty after consumption @@ -313,8 +313,8 @@ suite('Protocol WebSocket — Session Features', function () { }); assert.ok(secondComplete, 'should receive a second turnComplete from the queued message'); - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as ISessionState; + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as SessionState; assert.ok(state.turns.length >= 2, `expected >= 2 turns but got ${state.turns.length}`); }); @@ -352,8 +352,8 @@ suite('Protocol WebSocket — Session Features', function () { await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); // Steering should be cleared from state - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as ISessionState; + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as SessionState; assert.ok(!state.steeringMessage, 'steering message should be cleared after consumption'); }); @@ -373,8 +373,8 @@ suite('Protocol WebSocket — Session Features', function () { await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-t2'); // Verify 2 turns exist - let snapshot = await client.call('subscribe', { resource: sessionUri }); - let state = snapshot.snapshot.state as ISessionState; + let snapshot = await client.call('subscribe', { resource: sessionUri }); + let state = snapshot.snapshot.state as SessionState; assert.strictEqual(state.turns.length, 2); client.clearReceived(); @@ -387,8 +387,8 @@ suite('Protocol WebSocket — Session Features', function () { await client.waitForNotification(n => isActionNotification(n, 'session/truncated')); - snapshot = await client.call('subscribe', { resource: sessionUri }); - state = snapshot.snapshot.state as ISessionState; + snapshot = await client.call('subscribe', { resource: sessionUri }); + state = snapshot.snapshot.state as SessionState; assert.strictEqual(state.turns.length, 1); assert.strictEqual(state.turns[0].id, 'turn-t1'); }); @@ -411,8 +411,8 @@ suite('Protocol WebSocket — Session Features', function () { await client.waitForNotification(n => isActionNotification(n, 'session/truncated')); - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as ISessionState; + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as SessionState; assert.strictEqual(state.turns.length, 0); }); @@ -442,8 +442,8 @@ suite('Protocol WebSocket — Session Features', function () { dispatchTurnStarted(client, sessionUri, 'turn-tr3', 'hello', 4); await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as ISessionState; + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as SessionState; assert.strictEqual(state.turns.length, 2); assert.strictEqual(state.turns[0].id, 'turn-tr1'); assert.strictEqual(state.turns[1].id, 'turn-tr3'); @@ -477,17 +477,17 @@ suite('Protocol WebSocket — Session Features', function () { const addedNotif = await client.waitForNotification(n => n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' ); - const addedSession = (addedNotif.params as INotificationBroadcastParams).notification as ISessionAddedNotification; + const addedSession = (addedNotif.params as INotificationBroadcastParams).notification as SessionAddedNotification; // Subscribe — forked session should have 1 turn - const snapshot = await client.call('subscribe', { resource: addedSession.summary.resource }); - const state = snapshot.snapshot.state as ISessionState; + const snapshot = await client.call('subscribe', { resource: addedSession.summary.resource }); + const state = snapshot.snapshot.state as SessionState; assert.strictEqual(state.lifecycle, 'ready'); assert.strictEqual(state.turns.length, 1, 'forked session should have 1 turn'); // Source session should be unaffected - const sourceSnapshot = await client.call('subscribe', { resource: sessionUri }); - const sourceState = sourceSnapshot.snapshot.state as ISessionState; + const sourceSnapshot = await client.call('subscribe', { resource: sessionUri }); + const sourceState = sourceSnapshot.snapshot.state as SessionState; assert.strictEqual(sourceState.turns.length, 2); }); diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts index a11b39df6ee4e..7fb556c1266b2 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts @@ -6,11 +6,11 @@ import assert from 'assert'; import { timeout } from '../../../../../base/common/async.js'; import { URI } from '../../../../../base/common/uri.js'; -import { ISubscribeResult } from '../../../common/state/protocol/commands.js'; -import type { ISessionAddedNotification, ISessionRemovedNotification } from '../../../common/state/sessionActions.js'; +import { SubscribeResult } from '../../../common/state/protocol/commands.js'; +import type { SessionAddedNotification, SessionRemovedNotification } from '../../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; -import type { IListSessionsResult, INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js'; -import { ResponsePartKind, type IMarkdownResponsePart, type ISessionState, type IToolCallResponsePart } from '../../../common/state/sessionState.js'; +import type { ListSessionsResult, INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js'; +import { ResponsePartKind, type MarkdownResponsePart, type SessionState, type ToolCallResponsePart } from '../../../common/state/sessionState.js'; import { PRE_EXISTING_SESSION_URI } from '../mockAgent.js'; import { createAndSubscribeSession, @@ -55,7 +55,7 @@ suite('Protocol WebSocket — Session Lifecycle', function () { const notif = await client.waitForNotification(n => n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' ); - const notification = (notif.params as INotificationBroadcastParams).notification as ISessionAddedNotification; + const notification = (notif.params as INotificationBroadcastParams).notification as SessionAddedNotification; assert.strictEqual(URI.parse(notification.summary.resource).scheme, 'mock'); assert.strictEqual(notification.summary.provider, 'mock'); }); @@ -70,7 +70,7 @@ suite('Protocol WebSocket — Session Lifecycle', function () { n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' ); - const result = await client.call('listSessions'); + const result = await client.call('listSessions'); assert.ok(Array.isArray(result.items)); assert.ok(result.items.length >= 1, 'should have at least one session'); }); @@ -84,7 +84,7 @@ suite('Protocol WebSocket — Session Lifecycle', function () { const notif = await client.waitForNotification(n => n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionRemoved' ); - const removed = (notif.params as INotificationBroadcastParams).notification as ISessionRemovedNotification; + const removed = (notif.params as INotificationBroadcastParams).notification as SessionRemovedNotification; assert.strictEqual(removed.session.toString(), sessionUri.toString()); }); @@ -97,7 +97,7 @@ suite('Protocol WebSocket — Session Lifecycle', function () { // through the server's handleCreateSession -- simulating a session // from a previous server lifetime. const preExistingUri = PRE_EXISTING_SESSION_URI.toString(); - const list = await client.call('listSessions'); + const list = await client.call('listSessions'); const preExisting = list.items.find(s => s.resource === preExistingUri); assert.ok(preExisting, 'listSessions should include the pre-existing session'); @@ -106,8 +106,8 @@ suite('Protocol WebSocket — Session Lifecycle', function () { // Subscribing to this session should trigger the restore path: the // server fetches message history from the agent and reconstructs turns. - const result = await client.call('subscribe', { resource: preExistingUri }); - const state = result.snapshot.state as ISessionState; + const result = await client.call('subscribe', { resource: preExistingUri }); + const state = result.snapshot.state as SessionState; assert.strictEqual(state.lifecycle, 'ready', 'restored session should be in ready state'); assert.ok(state.turns.length >= 1, `expected at least 1 restored turn but got ${state.turns.length}`); @@ -115,10 +115,10 @@ suite('Protocol WebSocket — Session Lifecycle', function () { const turn = state.turns[0]; assert.strictEqual(turn.userMessage.text, 'What files are here?'); assert.strictEqual(turn.state, 'complete'); - const toolCallParts = turn.responseParts.filter((p): p is IToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); + const toolCallParts = turn.responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); assert.ok(toolCallParts.length >= 1, 'turn should have tool call response parts'); assert.strictEqual(toolCallParts[0].toolCall.toolName, 'list_files'); - const mdParts = turn.responseParts.filter((p): p is IMarkdownResponsePart => p.kind === ResponsePartKind.Markdown); + const mdParts = turn.responseParts.filter((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown); assert.ok(mdParts.some(p => p.content.includes('file1.ts')), 'turn should have markdown part mentioning file1.ts'); // Restoring should NOT emit a duplicate sessionAdded notification @@ -160,8 +160,8 @@ suite('Protocol WebSocket — Session Lifecycle', function () { await client.waitForNotification(n => isActionNotification(n, 'session/isReadChanged')); // Verify the flags are reflected in the subscribed session state - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as ISessionState; + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as SessionState; assert.strictEqual(state.summary.isDone, true, 'isDone should be true in snapshot'); assert.strictEqual(state.summary.isRead, true, 'isRead should be true in snapshot'); @@ -171,9 +171,9 @@ suite('Protocol WebSocket — Session Lifecycle', function () { await client2.connect(); await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-read-done-flags-2' }); - let session: IListSessionsResult['items'][0] | undefined; + let session: ListSessionsResult['items'][0] | undefined; for (let i = 0; i < 20; i++) { - const result = await client2.call('listSessions'); + const result = await client2.call('listSessions'); session = result.items.find(s => s.resource === sessionUri); if (session?.isDone === true && session?.isRead === true) { break; @@ -211,9 +211,9 @@ suite('Protocol WebSocket — Session Lifecycle', function () { await client2.connect(); await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-isread-false-2' }); - let session: IListSessionsResult['items'][0] | undefined; + let session: ListSessionsResult['items'][0] | undefined; for (let i = 0; i < 20; i++) { - const result = await client2.call('listSessions'); + const result = await client2.call('listSessions'); session = result.items.find(s => s.resource === sessionUri); if (session && session.isRead === false) { break; diff --git a/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts b/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts index 11df92955996d..ffd4b32550d62 100644 --- a/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts +++ b/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts @@ -7,17 +7,17 @@ import { ChildProcess, fork } from 'child_process'; import { fileURLToPath } from 'url'; import { WebSocket } from 'ws'; import { URI } from '../../../../../base/common/uri.js'; -import { ISubscribeResult } from '../../../common/state/protocol/commands.js'; -import type { IActionEnvelope, ISessionAddedNotification } from '../../../common/state/sessionActions.js'; +import { SubscribeResult } from '../../../common/state/protocol/commands.js'; +import type { ActionEnvelope, SessionAddedNotification } from '../../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; import { isJsonRpcNotification, isJsonRpcResponse, - type IAhpNotification, - type IJsonRpcErrorResponse, - type IJsonRpcSuccessResponse, + type AhpNotification, + type JsonRpcErrorResponse, + type JsonRpcSuccessResponse, type INotificationBroadcastParams, - type IProtocolMessage, + type ProtocolMessage, } from '../../../common/state/sessionProtocol.js'; // ---- JSON-RPC test client --------------------------------------------------- @@ -31,8 +31,8 @@ export class TestProtocolClient { private readonly _ws: WebSocket; private _nextId = 1; private readonly _pendingCalls = new Map(); - private readonly _notifications: IAhpNotification[] = []; - private readonly _notifWaiters: { predicate: (n: IAhpNotification) => boolean; resolve: (n: IAhpNotification) => void; reject: (err: Error) => void }[] = []; + private readonly _notifications: AhpNotification[] = []; + private readonly _notifWaiters: { predicate: (n: AhpNotification) => boolean; resolve: (n: AhpNotification) => void; reject: (err: Error) => void }[] = []; constructor(port: number) { this._ws = new WebSocket(`ws://127.0.0.1:${port}`); @@ -52,16 +52,16 @@ export class TestProtocolClient { }); } - private _handleMessage(msg: IProtocolMessage): void { + private _handleMessage(msg: ProtocolMessage): void { if (isJsonRpcResponse(msg)) { const pending = this._pendingCalls.get(msg.id); if (pending) { this._pendingCalls.delete(msg.id); - const errResp = msg as IJsonRpcErrorResponse; + const errResp = msg as JsonRpcErrorResponse; if (errResp.error) { pending.reject(new Error(errResp.error.message)); } else { - pending.resolve((msg as IJsonRpcSuccessResponse).result); + pending.resolve((msg as JsonRpcSuccessResponse).result); } } } else if (isJsonRpcNotification(msg)) { @@ -99,13 +99,13 @@ export class TestProtocolClient { } /** Wait for a server notification matching a predicate. */ - waitForNotification(predicate: (n: IAhpNotification) => boolean, timeoutMs = 5000): Promise { + waitForNotification(predicate: (n: AhpNotification) => boolean, timeoutMs = 5000): Promise { const existing = this._notifications.find(predicate); if (existing) { return Promise.resolve(existing); } - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const timer = setTimeout(() => { const idx = this._notifWaiters.findIndex(w => w.resolve === resolve); if (idx >= 0) { @@ -123,7 +123,7 @@ export class TestProtocolClient { } /** Return all received notifications matching a predicate. */ - receivedNotifications(predicate?: (n: IAhpNotification) => boolean): IAhpNotification[] { + receivedNotifications(predicate?: (n: AhpNotification) => boolean): AhpNotification[] { return predicate ? this._notifications.filter(predicate) : [...this._notifications]; } @@ -271,16 +271,16 @@ export function nextSessionUri(): string { return URI.from({ scheme: 'mock', path: `/test-session-${++sessionCounter}` }).toString(); } -export function isActionNotification(n: IAhpNotification, actionType: string): boolean { +export function isActionNotification(n: AhpNotification, actionType: string): boolean { if (n.method !== 'action') { return false; } - const envelope = n.params as unknown as IActionEnvelope; + const envelope = n.params as unknown as ActionEnvelope; return envelope.action.type === actionType; } -export function getActionEnvelope(n: IAhpNotification): IActionEnvelope { - return n.params as unknown as IActionEnvelope; +export function getActionEnvelope(n: AhpNotification): ActionEnvelope { + return n.params as unknown as ActionEnvelope; } /** Perform handshake, create a session, subscribe, and return its URI. */ @@ -292,9 +292,9 @@ export async function createAndSubscribeSession(c: TestProtocolClient, clientId: const notif = await c.waitForNotification(n => n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' ); - const realSessionUri = ((notif.params as INotificationBroadcastParams).notification as ISessionAddedNotification).summary.resource; + const realSessionUri = ((notif.params as INotificationBroadcastParams).notification as SessionAddedNotification).summary.resource; - await c.call('subscribe', { resource: realSessionUri }); + await c.call('subscribe', { resource: realSessionUri }); c.clearReceived(); return realSessionUri; diff --git a/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts index 1485517adc71c..13a2bc6cf49c3 100644 --- a/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import type { IResponsePartAction } from '../../../common/state/sessionActions.js'; -import { ResponsePartKind, type IMarkdownResponsePart } from '../../../common/state/sessionState.js'; +import { ResponsePartKind, type MarkdownResponsePart } from '../../../common/state/sessionState.js'; import { createAndSubscribeSession, dispatchTurnStarted, @@ -67,7 +67,7 @@ suite('Protocol WebSocket — Permissions & Auto-Approve', function () { const responsePart = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); const responsePartAction = getActionEnvelope(responsePart).action as IResponsePartAction; assert.strictEqual(responsePartAction.part.kind, ResponsePartKind.Markdown); - assert.strictEqual((responsePartAction.part as IMarkdownResponsePart).content, 'Allowed.'); + assert.strictEqual((responsePartAction.part as MarkdownResponsePart).content, 'Allowed.'); await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); }); diff --git a/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts index bc9d8ac4e16aa..3ff7eb9b9e78d 100644 --- a/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts @@ -27,12 +27,12 @@ import { mkdtempSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { removeAnsiEscapeCodes } from '../../../../../base/common/strings.js'; import { URI } from '../../../../../base/common/uri.js'; -import type { ISessionToolCallStartAction } from '../../../common/state/protocol/actions.js'; -import { ISubscribeResult } from '../../../common/state/protocol/commands.js'; +import type { SessionToolCallStartAction } from '../../../common/state/protocol/actions.js'; +import { SubscribeResult } from '../../../common/state/protocol/commands.js'; import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; -import { ResponsePartKind, ROOT_STATE_URI, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType, isSubagentSession, type ISessionInputAnswer, type ISessionInputRequest, type ISessionState, type ITerminalState, type IToolResultContent, type IToolResultSubagentContent } from '../../../common/state/sessionState.js'; -import type { IRootState } from '../../../common/state/protocol/state.js'; -import type { IRootAgentsChangedAction, ISessionAddedNotification, ISessionInputRequestedAction, ISessionResponsePartAction, ISessionToolCallReadyAction } from '../../../common/state/sessionActions.js'; +import { ResponsePartKind, ROOT_STATE_URI, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType, isSubagentSession, type SessionInputAnswer, type SessionInputRequest, type SessionState, type TerminalState, type ToolResultContent, type ToolResultSubagentContent } from '../../../common/state/sessionState.js'; +import type { RootState } from '../../../common/state/protocol/state.js'; +import type { RootAgentsChangedAction, SessionAddedNotification, SessionInputRequestedAction, SessionResponsePartAction, SessionToolCallReadyAction } from '../../../common/state/sessionActions.js'; import type { INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js'; import { getActionEnvelope, @@ -65,8 +65,8 @@ async function createRealSession(c: TestProtocolClient, clientId: string, tracki interface IRealSessionResult { sessionUri: string; - addedNotification: ISessionAddedNotification; - subscribeSnapshot: ISessionState; + addedNotification: SessionAddedNotification; + subscribeSnapshot: SessionState; } /** Full version that returns the sessionAdded notification and subscribe snapshot for assertions. */ @@ -82,12 +82,12 @@ async function createRealSessionFull(c: TestProtocolClient, clientId: string, tr n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded', 15_000, ); - const addedNotification = (notif.params as INotificationBroadcastParams).notification as ISessionAddedNotification; + const addedNotification = (notif.params as INotificationBroadcastParams).notification as SessionAddedNotification; const realSessionUri = addedNotification.summary.resource; trackingList.push(realSessionUri); - const subscribeResult = await c.call('subscribe', { resource: realSessionUri }); - const subscribeSnapshot = subscribeResult.snapshot.state as ISessionState; + const subscribeResult = await c.call('subscribe', { resource: realSessionUri }); + const subscribeSnapshot = subscribeResult.snapshot.state as SessionState; c.clearReceived(); return { sessionUri: realSessionUri, addedNotification, subscribeSnapshot }; @@ -106,7 +106,7 @@ function dispatchTurn(c: TestProtocolClient, session: string, turnId: string, te }); } -function getAcceptedAnswers(request: ISessionInputRequest): Record | undefined { +function getAcceptedAnswers(request: SessionInputRequest): Record | undefined { if (!request.questions?.length) { return undefined; } @@ -120,7 +120,7 @@ function getAcceptedAnswers(request: ISessionInputRequest): Record /interactive/i.test(option.id) || /interactive/i.test(option.label)) ?? question.options.find(option => option.recommended) @@ -148,7 +148,7 @@ function getAcceptedAnswers(request: ISessionInputRequest): Record option.recommended); @@ -159,7 +159,7 @@ function getAcceptedAnswers(request: ISessionInputRequest): Record option.id), }, - } satisfies ISessionInputAnswer]; + } satisfies SessionInputAnswer]; } } })); @@ -167,7 +167,7 @@ function getAcceptedAnswers(request: ISessionInputRequest): Record isActionNotification(n, 'session/responsePart')) - .map(notification => getActionEnvelope(notification).action as ISessionResponsePartAction) + .map(notification => getActionEnvelope(notification).action as SessionResponsePartAction) .flatMap(action => action.part.kind === ResponsePartKind.Markdown ? [action.part.content] : []) .join('\n'); } @@ -201,7 +201,7 @@ async function driveTurnToCompletion(c: TestProtocolClient, session: string, tur } if (isActionNotification(notification, 'session/toolCallReady')) { - const action = getActionEnvelope(notification).action as ISessionToolCallReadyAction; + const action = getActionEnvelope(notification).action as SessionToolCallReadyAction; if (!action.confirmed) { sawPendingConfirmation = true; c.notify('dispatchAction', { @@ -220,7 +220,7 @@ async function driveTurnToCompletion(c: TestProtocolClient, session: string, tur if (isActionNotification(notification, 'session/inputRequested')) { sawInputRequest = true; - const action = getActionEnvelope(notification).action as ISessionInputRequestedAction; + const action = getActionEnvelope(notification).action as SessionInputRequestedAction; c.notify('dispatchAction', { clientSeq: nextClientSeq++, action: { @@ -244,12 +244,12 @@ async function driveTurnToCompletion(c: TestProtocolClient, session: string, tur }; } -function terminalResourceFromContent(content: readonly IToolResultContent[]): string | undefined { +function terminalResourceFromContent(content: readonly ToolResultContent[]): string | undefined { const terminalContent = content.find(c => c.type === ToolResultContentType.Terminal); return terminalContent?.resource; } -function terminalText(state: ITerminalState): string { +function terminalText(state: TerminalState): string { return removeAnsiEscapeCodes(state.content.map(part => part.type === 'command' ? `${part.commandLine}\n${part.output}` : part.value).join('')); } @@ -354,7 +354,12 @@ function terminalText(state: ITerminalState): string { await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'), 90_000); }); - test('planning-mode session-state writes are auto-approved in default mode', async function () { + test.skip('planning-mode session-state writes are auto-approved in default mode', async function () { + // TODO: re-enable once exit_plan_mode is fully supported in @github/copilot-sdk. + // The public SDK currently lacks agentMode: 'plan' on MessageOptions and + // respondToExitPlanMode() on the session, so the model never calls exit_plan_mode + // and sawInputRequest never becomes true. + this.timeout(180_000); const tempDir = mkdtempSync(`${tmpdir()}/ahp-plan-test-`); @@ -382,8 +387,8 @@ function terminalText(state: ITerminalState): string { ); assert.strictEqual(extraSessionNotificationsAfterFollowup.length, 0, 'sending another message should stay on the same session instead of forking'); - const resubscribeResult = await client.call('subscribe', { resource: sessionUri }); - const finalSnapshot = resubscribeResult.snapshot.state as ISessionState; + const resubscribeResult = await client.call('subscribe', { resource: sessionUri }); + const finalSnapshot = resubscribeResult.snapshot.state as SessionState; assert.strictEqual(finalSnapshot.summary.resource, sessionUri, 'follow-up turn should keep the original session resource'); }); @@ -441,7 +446,7 @@ function terminalText(state: ITerminalState): string { n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded', 15_000, ); - const addedSummary = ((addedNotif.params as INotificationBroadcastParams).notification as ISessionAddedNotification).summary; + const addedSummary = ((addedNotif.params as INotificationBroadcastParams).notification as SessionAddedNotification).summary; createdSessions.push(addedSummary.resource); assert.strictEqual( addedSummary.workingDirectory, @@ -450,8 +455,8 @@ function terminalText(state: ITerminalState): string { ); // 2. Subscribe and verify workingDirectory in the session state snapshot - const subscribeResult = await client.call('subscribe', { resource: addedSummary.resource }); - const sessionState = subscribeResult.snapshot.state as ISessionState; + const subscribeResult = await client.call('subscribe', { resource: addedSummary.resource }); + const sessionState = subscribeResult.snapshot.state as SessionState; assert.strictEqual( sessionState.summary.workingDirectory, workingDirUri, @@ -489,11 +494,11 @@ function terminalText(state: ITerminalState): string { n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded', 15_000, ); - const addedSummary = ((addedNotif.params as INotificationBroadcastParams).notification as ISessionAddedNotification).summary; + const addedSummary = ((addedNotif.params as INotificationBroadcastParams).notification as SessionAddedNotification).summary; createdSessions.push(addedSummary.resource); // Subscribe so we receive action broadcasts for this session - await client.call('subscribe', { resource: addedSummary.resource }); + await client.call('subscribe', { resource: addedSummary.resource }); // Verify the worktree path is in the summary assert.ok( @@ -589,20 +594,20 @@ function terminalText(state: ITerminalState): string { if (!isActionNotification(n, 'session/toolCallContentChanged')) { return false; } - const action = getActionEnvelope(n).action as { toolCallId: string; content: readonly IToolResultContent[] }; + const action = getActionEnvelope(n).action as { toolCallId: string; content: readonly ToolResultContent[] }; return action.toolCallId === toolStartAction.toolCallId && terminalResourceFromContent(action.content) !== undefined; }, 30_000); - const terminalContentAction = getActionEnvelope(terminalContentNotif).action as { content: readonly IToolResultContent[] }; + const terminalContentAction = getActionEnvelope(terminalContentNotif).action as { content: readonly ToolResultContent[] }; const terminalUri = terminalResourceFromContent(terminalContentAction.content); assert.ok(terminalUri, 'shell tool should expose its terminal resource'); - const terminalSubscribeResult = await client.call('subscribe', { resource: terminalUri }); - const initialTerminalState = terminalSubscribeResult.snapshot.state as ITerminalState; + const terminalSubscribeResult = await client.call('subscribe', { resource: terminalUri }); + const initialTerminalState = terminalSubscribeResult.snapshot.state as TerminalState; assert.strictEqual(initialTerminalState.cwd, resolvedWorkingDirectoryPath, 'terminal should be created in the resolved worktree directory'); await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'), 90_000); - const terminalSnapshot = await client.call('subscribe', { resource: terminalUri }); - const terminalState = terminalSnapshot.snapshot.state as ITerminalState; + const terminalSnapshot = await client.call('subscribe', { resource: terminalUri }); + const terminalState = terminalSnapshot.snapshot.state as TerminalState; assert.ok(terminalText(terminalState).includes(resolvedWorkingDirectoryPath), `pwd output should include the resolved worktree path ${resolvedWorkingDirectoryPath}`); }); @@ -621,25 +626,41 @@ function terminalText(state: ITerminalState): string { // Auto-approve every tool that needs confirmation while the turn runs. // Multiple inner tool calls may need approval; doing this in a background - // loop keeps the turn unblocked. + // loop keeps the turn unblocked. Track processed serverSeqs so we don't + // busy-spin on already-handled notifications (waitForNotification returns + // matching notifications from the queue without consuming them). Using + // serverSeq rather than toolCallId allows the same tool to be legitimately + // re-confirmed in a later notification. let approvalsActive = true; let approvalSeq = 1000; + const processedSeqs = new Set(); const approvalLoop = (async () => { while (approvalsActive) { try { - const ready = await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady'), 2_000); - const action = getActionEnvelope(ready).action as { session: string; turnId: string; toolCallId: string; confirmed?: string }; - if (!action.confirmed) { - client.notify('dispatchAction', { - clientSeq: ++approvalSeq, - action: { - type: 'session/toolCallConfirmed', - session: action.session, - turnId: action.turnId, - toolCallId: action.toolCallId, - approved: true, - }, - }); + const ready = await client.waitForNotification(n => { + if (!isActionNotification(n, 'session/toolCallReady')) { + return false; + } + const envelope = getActionEnvelope(n); + const a = envelope.action as { confirmed?: string }; + return !a.confirmed && !processedSeqs.has(envelope.serverSeq); + }, 2_000); + const envelope = getActionEnvelope(ready); + if (!processedSeqs.has(envelope.serverSeq)) { + processedSeqs.add(envelope.serverSeq); + const action = envelope.action as { session: string; turnId: string; toolCallId: string; confirmed?: string }; + if (!action.confirmed) { + client.notify('dispatchAction', { + clientSeq: ++approvalSeq, + action: { + type: 'session/toolCallConfirmed', + session: action.session, + turnId: action.turnId, + toolCallId: action.toolCallId, + approved: true, + }, + }); + } } } catch { // Timeout — re-poll. Loop exits when approvalsActive flips. @@ -662,18 +683,18 @@ function terminalText(state: ITerminalState): string { if (!isActionNotification(n, 'session/toolCallContentChanged')) { return false; } - const action = getActionEnvelope(n).action as { session: string; content: readonly IToolResultContent[] }; + const action = getActionEnvelope(n).action as { session: string; content: readonly ToolResultContent[] }; return action.session === sessionUri && action.content.some(c => c.type === ToolResultContentType.Subagent); }, 120_000); - const parentContent = (getActionEnvelope(subagentContentNotif).action as { content: readonly IToolResultContent[] }).content; - const subagentRef = parentContent.find((c): c is IToolResultSubagentContent => c.type === ToolResultContentType.Subagent)!; + const parentContent = (getActionEnvelope(subagentContentNotif).action as { content: readonly ToolResultContent[] }).content; + const subagentRef = parentContent.find((c): c is ToolResultSubagentContent => c.type === ToolResultContentType.Subagent)!; const subagentSessionUri = subagentRef.resource as unknown as string; assert.ok(typeof subagentSessionUri === 'string' && isSubagentSession(subagentSessionUri), `subagent session URI should be subagent-shaped, got: ${JSON.stringify(subagentSessionUri)}`); // Subscribe so we receive the subagent session's own action broadcasts. - await client.call('subscribe', { resource: subagentSessionUri }); + await client.call('subscribe', { resource: subagentSessionUri }); // Wait for the parent turn to complete (with a generous timeout — the // subagent's turn must finish first). @@ -691,7 +712,7 @@ function terminalText(state: ITerminalState): string { // This is the bug's signature: when inner tool_start arrives before // subagent_started, the inner tool calls leak into the parent session. const toolStarts = client.receivedNotifications(n => isActionNotification(n, 'session/toolCallStart')) - .map(n => getActionEnvelope(n).action as ISessionToolCallStartAction); + .map(n => getActionEnvelope(n).action as SessionToolCallStartAction); const parentStarts = toolStarts.filter(a => (a.session as unknown as string) === sessionUri); const subagentStarts = toolStarts.filter(a => (a.session as unknown as string) === subagentSessionUri); @@ -722,8 +743,8 @@ function terminalText(state: ITerminalState): string { // Subscribe to root state *before* authenticating so we can observe // the agentsChanged action that carries the populated model list. - const rootResult = await client.call('subscribe', { resource: ROOT_STATE_URI }, 30_000); - const initial = rootResult.snapshot.state as IRootState; + const rootResult = await client.call('subscribe', { resource: ROOT_STATE_URI }, 30_000); + const initial = rootResult.snapshot.state as RootState; const copilotAgent = initial.agents.find(a => a.provider === 'copilotcli'); assert.ok(copilotAgent, `Expected copilotcli agent in root state, got: ${initial.agents.map(a => a.provider).join(', ')}`); @@ -735,29 +756,31 @@ function terminalText(state: ITerminalState): string { if (!isActionNotification(n, 'root/agentsChanged')) { return false; } - const action = getActionEnvelope(n).action as IRootAgentsChangedAction; + const action = getActionEnvelope(n).action as RootAgentsChangedAction; const agent = action.agents.find(a => a.provider === 'copilotcli'); return !!agent && agent.models.length > 0; }, 30_000); - const action = getActionEnvelope(notif).action as IRootAgentsChangedAction; + const action = getActionEnvelope(notif).action as RootAgentsChangedAction; const agent = action.agents.find(a => a.provider === 'copilotcli')!; assert.ok(agent.models.length > 0, 'Expected at least one model from listModels'); // Assert every model has the shape CopilotAgent._listModels produces. - // If the SDK changes and any required field becomes undefined (as - // happened with max_context_window_tokens in @github/copilot@1.0.34), - // this loop surfaces the exact offending model instead of letting - // _refreshModels silently swallow the TypeError and set models=[]. + // maxContextWindow is optional because synthetic SDK entries (e.g. the + // `auto` router) ship with `capabilities: {}` and no fixed window. for (const model of agent.models) { assert.strictEqual(typeof model.id, 'string', `model.id should be a string: ${JSON.stringify(model)}`); assert.ok(model.id.length > 0, `model.id should be non-empty: ${JSON.stringify(model)}`); assert.strictEqual(typeof model.name, 'string', `model.name should be a string: ${JSON.stringify(model)}`); assert.strictEqual(model.provider, 'copilotcli', `model.provider should be copilotcli: ${JSON.stringify(model)}`); - assert.strictEqual(typeof model.maxContextWindow, 'number', `model.maxContextWindow should be a number: ${JSON.stringify(model)}`); - assert.ok(model.maxContextWindow && model.maxContextWindow > 0, `model.maxContextWindow should be positive: ${JSON.stringify(model)}`); + assert.ok(model.maxContextWindow === undefined || (typeof model.maxContextWindow === 'number' && model.maxContextWindow > 0), + `model.maxContextWindow should be undefined or a positive number: ${JSON.stringify(model)}`); assert.ok(model.supportsVision === undefined || typeof model.supportsVision === 'boolean', `model.supportsVision should be boolean or undefined: ${JSON.stringify(model)}`); } + + // The `auto` synthetic router model should be present even though it + // has no fixed context window. + assert.ok(agent.models.some(m => m.id === 'auto'), `Expected 'auto' model in list, got: ${agent.models.map(m => m.id).join(', ')}`); }); }); diff --git a/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts index 36100c223ef28..5a4cf072d5141 100644 --- a/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts @@ -4,10 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { ISubscribeResult } from '../../../common/state/protocol/commands.js'; +import { SubscribeResult } from '../../../common/state/protocol/commands.js'; import type { IResponsePartAction } from '../../../common/state/sessionActions.js'; -import type { IFetchTurnsResult } from '../../../common/state/sessionProtocol.js'; -import { ResponsePartKind, buildSubagentSessionUri, type IMarkdownResponsePart, type ISessionState } from '../../../common/state/sessionState.js'; +import type { FetchTurnsResult } from '../../../common/state/sessionProtocol.js'; +import { ResponsePartKind, buildSubagentSessionUri, type MarkdownResponsePart, type SessionState } from '../../../common/state/sessionState.js'; import { createAndSubscribeSession, dispatchTurnStarted, @@ -51,7 +51,7 @@ suite('Protocol WebSocket — Turn Execution', function () { const responsePart = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); const responsePartAction = getActionEnvelope(responsePart).action as IResponsePartAction; assert.strictEqual(responsePartAction.part.kind, ResponsePartKind.Markdown); - assert.strictEqual((responsePartAction.part as IMarkdownResponsePart).content, 'Hello, world!'); + assert.strictEqual((responsePartAction.part as MarkdownResponsePart).content, 'Hello, world!'); await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); }); @@ -99,8 +99,8 @@ suite('Protocol WebSocket — Turn Execution', function () { await client.waitForNotification(n => isActionNotification(n, 'session/turnCancelled')); - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as ISessionState; + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as SessionState; assert.ok(state.turns.length >= 1); assert.strictEqual(state.turns[state.turns.length - 1].state, 'cancelled'); }); @@ -117,8 +117,8 @@ suite('Protocol WebSocket — Turn Execution', function () { await new Promise(resolve => setTimeout(resolve, 200)); await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as ISessionState; + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as SessionState; assert.ok(state.turns.length >= 2, `expected >= 2 turns but got ${state.turns.length}`); assert.strictEqual(state.turns[0].id, 'turn-m1'); assert.strictEqual(state.turns[1].id, 'turn-m2'); @@ -136,7 +136,7 @@ suite('Protocol WebSocket — Turn Execution', function () { await new Promise(resolve => setTimeout(resolve, 200)); await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - const result = await client.call('fetchTurns', { session: sessionUri, limit: 10 }); + const result = await client.call('fetchTurns', { session: sessionUri, limit: 10 }); assert.ok(result.turns.length >= 2); assert.strictEqual(typeof result.hasMore, 'boolean'); }); @@ -154,8 +154,8 @@ suite('Protocol WebSocket — Turn Execution', function () { await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as ISessionState; + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as SessionState; assert.ok(state.turns.length >= 1); const turn = state.turns[state.turns.length - 1]; assert.ok(turn.usage); @@ -168,16 +168,16 @@ suite('Protocol WebSocket — Turn Execution', function () { const sessionUri = await createAndSubscribeSession(client, 'test-modifiedAt'); - const initialSnapshot = await client.call('subscribe', { resource: sessionUri }); - const initialModifiedAt = (initialSnapshot.snapshot.state as ISessionState).summary.modifiedAt; + const initialSnapshot = await client.call('subscribe', { resource: sessionUri }); + const initialModifiedAt = (initialSnapshot.snapshot.state as SessionState).summary.modifiedAt; await new Promise(resolve => setTimeout(resolve, 50)); dispatchTurnStarted(client, sessionUri, 'turn-mod', 'hello', 1); await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - const updatedSnapshot = await client.call('subscribe', { resource: sessionUri }); - const updatedModifiedAt = (updatedSnapshot.snapshot.state as ISessionState).summary.modifiedAt; + const updatedSnapshot = await client.call('subscribe', { resource: sessionUri }); + const updatedModifiedAt = (updatedSnapshot.snapshot.state as SessionState).summary.modifiedAt; assert.ok(updatedModifiedAt >= initialModifiedAt); }); @@ -194,10 +194,10 @@ suite('Protocol WebSocket — Turn Execution', function () { // the parent session URI + parent toolCallId. const childUri = buildSubagentSessionUri(sessionUri, 'tc-task-1'); - const parentSnapshot = await client.call('subscribe', { resource: sessionUri }); - const parentState = parentSnapshot.snapshot.state as ISessionState; - const childSnapshot = await client.call('subscribe', { resource: childUri }); - const childState = childSnapshot.snapshot.state as ISessionState; + const parentSnapshot = await client.call('subscribe', { resource: sessionUri }); + const parentState = parentSnapshot.snapshot.state as SessionState; + const childSnapshot = await client.call('subscribe', { resource: childUri }); + const childState = childSnapshot.snapshot.state as SessionState; // Parent turn should contain the `task` tool call but NOT the inner one. const parentTurn = parentState.turns[parentState.turns.length - 1]; diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index aab3c61cc0c88..191e01f71539e 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -9,12 +9,12 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; -import { type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentService, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type IAuthenticateParams, type IAuthenticateResult } from '../../common/agentService.js'; -import { IListSessionsResult, IResourceReadResult, IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; -import { ActionType, type ISessionAction } from '../../common/state/sessionActions.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 { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js'; -import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IAhpNotification, type IInitializeResult, type IProtocolMessage, type IReconnectResult, type IResourceListResult, type IResourceWriteParams, type IResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; -import { SessionStatus, type ISessionSummary } from '../../common/state/sessionState.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'; import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js'; import { ProtocolServerHandler } from '../../node/protocolServerHandler.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; @@ -23,21 +23,21 @@ import { AgentHostFileSystemProvider } from '../../common/agentHostFileSystemPro // ---- Mock helpers ----------------------------------------------------------- class MockProtocolTransport implements IProtocolTransport { - private readonly _onMessage = new Emitter(); + private readonly _onMessage = new Emitter(); readonly onMessage = this._onMessage.event; - private readonly _onDidSend = new Emitter(); + private readonly _onDidSend = new Emitter(); readonly onDidSend = this._onDidSend.event; private readonly _onClose = new Emitter(); readonly onClose = this._onClose.event; - readonly sent: IProtocolMessage[] = []; + readonly sent: ProtocolMessage[] = []; - send(message: IProtocolMessage): void { + send(message: ProtocolMessage): void { this.sent.push(message); this._onDidSend.fire(message); } - simulateMessage(msg: IProtocolMessage): void { + simulateMessage(msg: ProtocolMessage): void { this._onMessage.fire(msg); } @@ -68,13 +68,13 @@ class MockProtocolServer implements IProtocolServer { class MockAgentService implements IAgentService { declare readonly _serviceBrand: undefined; - readonly handledActions: ISessionAction[] = []; + readonly handledActions: SessionAction[] = []; readonly browsedUris: URI[] = []; readonly browseErrors = new Map(); readonly listedSessions: IAgentSessionMetadata[] = []; readonly createSessionConfigs: (IAgentCreateSessionConfig | undefined)[] = []; - private readonly _onDidAction = new Emitter(); + private readonly _onDidAction = new Emitter(); readonly onDidAction = this._onDidAction.event; private readonly _onDidNotification = new Emitter(); readonly onDidNotification = this._onDidNotification.event; @@ -86,7 +86,7 @@ class MockAgentService implements IAgentService { this._stateManager = sm; } - dispatchAction(action: ISessionAction, clientId: string, clientSeq: number): void { + dispatchAction(action: SessionAction, clientId: string, clientSeq: number): void { this.handledActions.push(action); const origin = { clientId, clientSeq }; this._stateManager.dispatchClientAction(action, origin); @@ -107,8 +107,8 @@ class MockAgentService implements IAgentService { return session; } - async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise { return { schema: { type: 'object', properties: {} }, values: {} }; } - async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { return { items: [] }; } + async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise { return { schema: { type: 'object', properties: {} }, values: {} }; } + async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { return { items: [] }; } async disposeSession(_session: URI): Promise { } async listSessions(): Promise { return this.listedSessions; } async subscribe(resource: URI): Promise { @@ -120,9 +120,9 @@ class MockAgentService implements IAgentService { } unsubscribe(_resource: URI): void { } async shutdown(): Promise { } - async authenticate(_params: IAuthenticateParams): Promise { return { authenticated: true }; } - async resourceWrite(_params: IResourceWriteParams): Promise { return {}; } - async resourceList(uri: URI): Promise { + async authenticate(_params: AuthenticateParams): Promise { return { authenticated: true }; } + async resourceWrite(_params: ResourceWriteParams): Promise { return {}; } + async resourceList(uri: URI): Promise { this.browsedUris.push(uri); const error = this.browseErrors.get(uri.toString()); if (error) { @@ -135,7 +135,7 @@ class MockAgentService implements IAgentService { ], }; } - async resourceRead(_uri: URI): Promise { + async resourceRead(_uri: URI): Promise { throw new Error('Not implemented'); } async resourceCopy(): Promise<{}> { return {}; } @@ -152,23 +152,23 @@ class MockAgentService implements IAgentService { // ---- Helpers ---------------------------------------------------------------- -function notification(method: string, params?: unknown): IProtocolMessage { - return { jsonrpc: '2.0', method, params } as IProtocolMessage; +function notification(method: string, params?: unknown): ProtocolMessage { + return { jsonrpc: '2.0', method, params } as ProtocolMessage; } -function request(id: number, method: string, params?: unknown): IProtocolMessage { - return { jsonrpc: '2.0', id, method, params } as IProtocolMessage; +function request(id: number, method: string, params?: unknown): ProtocolMessage { + return { jsonrpc: '2.0', id, method, params } as ProtocolMessage; } -function findNotifications(sent: IProtocolMessage[], method: string): IAhpNotification[] { - return sent.filter(isJsonRpcNotification) as IAhpNotification[]; +function findNotifications(sent: ProtocolMessage[], method: string): AhpNotification[] { + return sent.filter(isJsonRpcNotification) as AhpNotification[]; } -function findResponse(sent: IProtocolMessage[], id: number): IProtocolMessage | undefined { - return sent.find(isJsonRpcResponse) as IProtocolMessage | undefined; +function findResponse(sent: ProtocolMessage[], id: number): ProtocolMessage | undefined { + return sent.find(isJsonRpcResponse) as ProtocolMessage | undefined; } -function waitForResponse(transport: MockProtocolTransport, id: number): Promise { +function waitForResponse(transport: MockProtocolTransport, id: number): Promise { return Event.toPromise(Event.filter(transport.onDidSend, message => isJsonRpcResponse(message) && message.id === id)); } @@ -184,7 +184,7 @@ suite('ProtocolServerHandler', () => { const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString(); - function makeSessionSummary(resource?: string): ISessionSummary { + function makeSessionSummary(resource?: string): SessionSummary { return { resource: resource ?? sessionUri, provider: 'copilot', @@ -235,7 +235,7 @@ suite('ProtocolServerHandler', () => { const resp = findResponse(transport.sent, 1); assert.ok(resp, 'should have sent initialize response'); - const result = (resp as { result: IInitializeResult }).result; + const result = (resp as { result: InitializeResult }).result; assert.strictEqual(result.protocolVersion, PROTOCOL_VERSION); assert.strictEqual(result.serverSeq, stateManager.serverSeq); }); @@ -247,7 +247,7 @@ suite('ProtocolServerHandler', () => { const resp = findResponse(transport.sent, 1); assert.ok(resp); - const result = (resp as { result: IInitializeResult }).result; + const result = (resp as { result: InitializeResult }).result; assert.strictEqual(result.snapshots.length, 1); assert.strictEqual(result.snapshots[0].resource.toString(), sessionUri.toString()); }); @@ -344,7 +344,7 @@ suite('ProtocolServerHandler', () => { transport.simulateMessage(request(2, 'listSessions')); const resp = await responsePromise; - const result = (resp as unknown as { result: IListSessionsResult }).result; + const result = (resp as unknown as { result: ListSessionsResult }).result; assert.deepStrictEqual(result.items.map(item => item.project), [{ uri: URI.file('/workspace/project').toString(), displayName: 'Project' }]); }); @@ -363,7 +363,7 @@ suite('ProtocolServerHandler', () => { transport.simulateMessage(request(2, 'listSessions')); const resp = await responsePromise; - const result = (resp as unknown as { result: IListSessionsResult }).result; + const result = (resp as unknown as { result: ListSessionsResult }).result; assert.deepStrictEqual(result.items.map(item => item.project), [undefined]); }); @@ -395,7 +395,7 @@ suite('ProtocolServerHandler', () => { transport.simulateMessage(request(2, 'listSessions')); const resp = await responsePromise; - const result = (resp as unknown as { result: IListSessionsResult }).result; + const result = (resp as unknown as { result: ListSessionsResult }).result; assert.deepStrictEqual(result.items[0].diffs, [ { before: { uri: URI.file('/workspace/file.ts').toString(), content: { uri: 'content://before-ref' } }, @@ -426,7 +426,7 @@ suite('ProtocolServerHandler', () => { }); assert.deepStrictEqual({ result: (resp as { result: null }).result, - project: (added!.params as { notification: { summary: ISessionSummary } }).notification.summary.project, + project: (added!.params as { notification: { summary: SessionSummary } }).notification.summary.project, }, { result: null, project: { uri: 'file:///created-project', displayName: 'Created Project' }, @@ -439,7 +439,7 @@ suite('ProtocolServerHandler', () => { const transport1 = connectClient('client-r', [sessionUri]); const resp = findResponse(transport1.sent, 1); - const initSeq = (resp as { result: IInitializeResult }).result.serverSeq; + const initSeq = (resp as { result: InitializeResult }).result.serverSeq; transport1.simulateClose(); stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Title A' }); @@ -455,7 +455,7 @@ suite('ProtocolServerHandler', () => { const reconnectResp = findResponse(transport2.sent, 1); assert.ok(reconnectResp, 'should have sent reconnect response'); - const result = (reconnectResp as { result: IReconnectResult }).result; + const result = (reconnectResp as { result: ReconnectResult }).result; assert.strictEqual(result.type, 'replay'); if (result.type === 'replay') { assert.strictEqual(result.actions.length, 2); @@ -483,7 +483,7 @@ suite('ProtocolServerHandler', () => { const reconnectResp = findResponse(transport2.sent, 1); assert.ok(reconnectResp, 'should have sent reconnect response'); - const result = (reconnectResp as { result: IReconnectResult }).result; + const result = (reconnectResp as { result: ReconnectResult }).result; assert.strictEqual(result.type, 'snapshot'); if (result.type === 'snapshot') { assert.ok(result.snapshots.length > 0, 'should contain snapshots'); @@ -509,7 +509,7 @@ suite('ProtocolServerHandler', () => { const resp = findResponse(transport.sent, 1); assert.ok(resp); - const result = (resp as { result: IInitializeResult }).result; + const result = (resp as { result: InitializeResult }).result; assert.strictEqual(URI.parse(result.defaultDirectory!).path, '/home/testuser'); }); diff --git a/src/vs/platform/agentHost/test/node/reducers.test.ts b/src/vs/platform/agentHost/test/node/reducers.test.ts index dee1e65a635fb..0c333e38fc71f 100644 --- a/src/vs/platform/agentHost/test/node/reducers.test.ts +++ b/src/vs/platform/agentHost/test/node/reducers.test.ts @@ -7,9 +7,9 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { sessionReducer } from '../../common/state/protocol/reducers.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, type ISessionState } from '../../common/state/sessionState.js'; +import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, type SessionState } from '../../common/state/sessionState.js'; -function makeSession(): ISessionState { +function makeSession(): SessionState { return { summary: { resource: 'copilot:/test', @@ -25,7 +25,7 @@ function makeSession(): ISessionState { }; } -function withActiveTurnAndToolCall(state: ISessionState): ISessionState { +function withActiveTurnAndToolCall(state: SessionState): SessionState { state = sessionReducer(state, { type: ActionType.SessionTurnStarted, session: 'copilot:/test', diff --git a/src/vs/platform/contextkey/browser/contextKeyService.ts b/src/vs/platform/contextkey/browser/contextKeyService.ts index 5896e8cc0baee..a865e8a47ca90 100644 --- a/src/vs/platform/contextkey/browser/contextKeyService.ts +++ b/src/vs/platform/contextkey/browser/contextKeyService.ts @@ -509,6 +509,9 @@ class ScopedContextKeyService extends AbstractContextKeyService { return; } + // Clear the parent change listener before disposeContext to avoid + // forwarding parent events after this service has begun tearing down. + this._parentChangeListener.clear(); this._parent.disposeContext(this._myContextId); this._domNode.removeAttribute(KEYBINDING_CONTEXT_ATTR); super.dispose(); diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index f4798ad941f08..9face95e1364b 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -75,6 +75,7 @@ export interface NativeParsedArgs { 'extensions-dir'?: string; 'extensions-download-dir'?: string; 'builtin-extensions-dir'?: string; + 'shared-data-dir'?: string; 'agent-plugins-dir'?: string; extensionDevelopmentPath?: string[]; // undefined or array of 1 or more local paths or URIs extensionTestsPath?: string; // either a local path or a URI diff --git a/src/vs/platform/environment/common/environment.ts b/src/vs/platform/environment/common/environment.ts index 7fa7b27710bb5..8b3017f475ca9 100644 --- a/src/vs/platform/environment/common/environment.ts +++ b/src/vs/platform/environment/common/environment.ts @@ -58,6 +58,7 @@ export interface IEnvironmentService { workspaceStorageHome: URI; localHistoryHome: URI; cacheHome: URI; + appSharedDataHome: URI; // --- settings sync userDataSyncHome: URI; diff --git a/src/vs/platform/environment/common/environmentService.ts b/src/vs/platform/environment/common/environmentService.ts index 262523a981e7c..1d7dfff7d1723 100644 --- a/src/vs/platform/environment/common/environmentService.ts +++ b/src/vs/platform/environment/common/environmentService.ts @@ -147,6 +147,21 @@ export abstract class AbstractNativeEnvironmentService implements INativeEnviron return joinPath(this.userHome, this.productService.dataFolderName, 'extensions').fsPath; } + @memoize + get appSharedDataHome(): URI { + const cliSharedDataDir = this.args['shared-data-dir']; + if (cliSharedDataDir) { + return URI.file(resolve(cliSharedDataDir)); + } + + const vscodePortable = env['VSCODE_PORTABLE']; + if (vscodePortable) { + return URI.file(join(vscodePortable, 'shared-data')); + } + + return joinPath(this.userHome, this.productService.sharedDataFolderName); + } + @memoize get agentPluginsPath(): string { const cliAgentPluginsDir = this.args['agent-plugins-dir']; diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index b6e854548c88b..bc0a5cb29c326 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -119,6 +119,7 @@ export const OPTIONS: OptionDescriptions> = { 'extensions-dir': { type: 'string', deprecates: ['extensionHomePath'], cat: 'e', args: 'dir', description: localize('extensionHomePath', "Set the root path for extensions.") }, 'extensions-download-dir': { type: 'string' }, 'builtin-extensions-dir': { type: 'string' }, + 'shared-data-dir': { type: 'string' }, 'list-extensions': { type: 'boolean', cat: 'e', description: localize('listExtensions', "List the installed extensions.") }, 'agent-plugins-dir': { type: 'string' }, 'show-versions': { type: 'boolean', cat: 'e', description: localize('showVersions', "Show versions of installed extensions, when using --list-extensions.") }, diff --git a/src/vs/platform/launch/electron-main/launchMainService.ts b/src/vs/platform/launch/electron-main/launchMainService.ts index 7815fdff84402..1faa200f8ce2f 100644 --- a/src/vs/platform/launch/electron-main/launchMainService.ts +++ b/src/vs/platform/launch/electron-main/launchMainService.ts @@ -6,7 +6,7 @@ import { app } from 'electron'; import { coalesce } from '../../../base/common/arrays.js'; import { Emitter, Event } from '../../../base/common/event.js'; -import { IProcessEnvironment, isMacintosh } from '../../../base/common/platform.js'; +import { IProcessEnvironment, isLinux, isMacintosh } from '../../../base/common/platform.js'; import { URI } from '../../../base/common/uri.js'; import { whenDeleted } from '../../../base/node/pfs.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; @@ -164,7 +164,7 @@ export class LaunchMainService implements ILaunchMainService { } // Agents window - else if (args['agents'] && this.productService.quality !== 'stable') { + else if (!isLinux && args['agents'] && this.productService.quality !== 'stable') { usedWindows = await this.windowsMainService.openAgentsWindow(baseConfig); } diff --git a/src/vs/platform/storage/common/storage.ts b/src/vs/platform/storage/common/storage.ts index d67cdc476805e..2e967d799f778 100644 --- a/src/vs/platform/storage/common/storage.ts +++ b/src/vs/platform/storage/common/storage.ts @@ -54,6 +54,10 @@ export interface IApplicationStorageValueChangeEvent extends IStorageValueChange readonly scope: StorageScope.APPLICATION; } +export interface IApplicationSharedStorageValueChangeEvent extends IStorageValueChangeEvent { + readonly scope: StorageScope.APPLICATION_SHARED; +} + export interface IStorageService { readonly _serviceBrand: undefined; @@ -69,6 +73,7 @@ export interface IStorageService { onDidChangeValue(scope: StorageScope.WORKSPACE, key: string | undefined, disposable: DisposableStore): Event; onDidChangeValue(scope: StorageScope.PROFILE, key: string | undefined, disposable: DisposableStore): Event; onDidChangeValue(scope: StorageScope.APPLICATION, key: string | undefined, disposable: DisposableStore): Event; + onDidChangeValue(scope: StorageScope.APPLICATION_SHARED, key: string | undefined, disposable: DisposableStore): Event; onDidChangeValue(scope: StorageScope, key: string | undefined, disposable: DisposableStore): Event; /** @@ -222,6 +227,12 @@ export interface IStorageService { export const enum StorageScope { + /** + * The stored data will be scoped to all workspaces across all profiles + * and shared across VS Code and Sessions app. + */ + APPLICATION_SHARED = -2, + /** * The stored data will be scoped to all workspaces across all profiles. */ @@ -340,6 +351,7 @@ export abstract class AbstractStorageService extends Disposable implements IStor onDidChangeValue(scope: StorageScope.WORKSPACE, key: string | undefined, disposable: DisposableStore): Event; onDidChangeValue(scope: StorageScope.PROFILE, key: string | undefined, disposable: DisposableStore): Event; onDidChangeValue(scope: StorageScope.APPLICATION, key: string | undefined, disposable: DisposableStore): Event; + onDidChangeValue(scope: StorageScope.APPLICATION_SHARED, key: string | undefined, disposable: DisposableStore): Event; onDidChangeValue(scope: StorageScope, key: string | undefined, disposable: DisposableStore): Event { return Event.filter(this._onDidChangeValue.event, e => e.scope === scope && (key === undefined || e.key === key), disposable); } @@ -398,6 +410,9 @@ export abstract class AbstractStorageService extends Disposable implements IStor // Clear our cached version which is now out of date switch (scope) { + case StorageScope.APPLICATION_SHARED: + this._applicationSharedKeyTargets = undefined; + break; case StorageScope.APPLICATION: this._applicationKeyTargets = undefined; break; @@ -564,8 +579,19 @@ export abstract class AbstractStorageService extends Disposable implements IStor return this._applicationKeyTargets; } + private _applicationSharedKeyTargets: IKeyTargets | undefined = undefined; + private get applicationSharedKeyTargets(): IKeyTargets { + if (!this._applicationSharedKeyTargets) { + this._applicationSharedKeyTargets = this.loadKeyTargets(StorageScope.APPLICATION_SHARED); + } + + return this._applicationSharedKeyTargets; + } + private getKeyTargets(scope: StorageScope): IKeyTargets { switch (scope) { + case StorageScope.APPLICATION_SHARED: + return this.applicationSharedKeyTargets; case StorageScope.APPLICATION: return this.applicationKeyTargets; case StorageScope.PROFILE: @@ -591,6 +617,7 @@ export abstract class AbstractStorageService extends Disposable implements IStor this._onWillSaveState.fire({ reason }); const applicationStorage = this.getStorage(StorageScope.APPLICATION); + const applicationSharedStorage = this.getStorage(StorageScope.APPLICATION_SHARED); const profileStorage = this.getStorage(StorageScope.PROFILE); const workspaceStorage = this.getStorage(StorageScope.WORKSPACE); @@ -600,6 +627,7 @@ export abstract class AbstractStorageService extends Disposable implements IStor case WillSaveStateReason.NONE: await Promises.settled([ applicationStorage?.whenFlushed() ?? Promise.resolve(), + applicationSharedStorage?.whenFlushed() ?? Promise.resolve(), profileStorage?.whenFlushed() ?? Promise.resolve(), workspaceStorage?.whenFlushed() ?? Promise.resolve() ]); @@ -610,6 +638,7 @@ export abstract class AbstractStorageService extends Disposable implements IStor case WillSaveStateReason.SHUTDOWN: await Promises.settled([ applicationStorage?.flush(0) ?? Promise.resolve(), + applicationSharedStorage?.flush(0) ?? Promise.resolve(), profileStorage?.flush(0) ?? Promise.resolve(), workspaceStorage?.flush(0) ?? Promise.resolve() ]); @@ -619,14 +648,17 @@ export abstract class AbstractStorageService extends Disposable implements IStor async log(): Promise { const applicationItems = this.getStorage(StorageScope.APPLICATION)?.items ?? new Map(); + const applicationSharedItems = this.getStorage(StorageScope.APPLICATION_SHARED)?.items ?? new Map(); const profileItems = this.getStorage(StorageScope.PROFILE)?.items ?? new Map(); const workspaceItems = this.getStorage(StorageScope.WORKSPACE)?.items ?? new Map(); return logStorage( applicationItems, + applicationSharedItems, profileItems, workspaceItems, this.getLogDetails(StorageScope.APPLICATION) ?? '', + this.getLogDetails(StorageScope.APPLICATION_SHARED) ?? '', this.getLogDetails(StorageScope.PROFILE) ?? '', this.getLogDetails(StorageScope.WORKSPACE) ?? '' ); @@ -707,6 +739,7 @@ export function isProfileUsingDefaultStorage(profile: IUserDataProfile): boolean export class InMemoryStorageService extends AbstractStorageService { private readonly applicationStorage = this._register(new Storage(new InMemoryStorageDatabase(), { hint: StorageHint.STORAGE_IN_MEMORY })); + private readonly applicationSharedStorage = this._register(new Storage(new InMemoryStorageDatabase(), { hint: StorageHint.STORAGE_IN_MEMORY })); private readonly profileStorage = this._register(new Storage(new InMemoryStorageDatabase(), { hint: StorageHint.STORAGE_IN_MEMORY })); private readonly workspaceStorage = this._register(new Storage(new InMemoryStorageDatabase(), { hint: StorageHint.STORAGE_IN_MEMORY })); @@ -716,10 +749,13 @@ export class InMemoryStorageService extends AbstractStorageService { this._register(this.workspaceStorage.onDidChangeStorage(e => this.emitDidChangeValue(StorageScope.WORKSPACE, e))); this._register(this.profileStorage.onDidChangeStorage(e => this.emitDidChangeValue(StorageScope.PROFILE, e))); this._register(this.applicationStorage.onDidChangeStorage(e => this.emitDidChangeValue(StorageScope.APPLICATION, e))); + this._register(this.applicationSharedStorage.onDidChangeStorage(e => this.emitDidChangeValue(StorageScope.APPLICATION_SHARED, e))); } protected getStorage(scope: StorageScope): IStorage { switch (scope) { + case StorageScope.APPLICATION_SHARED: + return this.applicationSharedStorage; case StorageScope.APPLICATION: return this.applicationStorage; case StorageScope.PROFILE: @@ -731,6 +767,8 @@ export class InMemoryStorageService extends AbstractStorageService { protected getLogDetails(scope: StorageScope): string | undefined { switch (scope) { + case StorageScope.APPLICATION_SHARED: + return 'inMemory (application-shared)'; case StorageScope.APPLICATION: return 'inMemory (application)'; case StorageScope.PROFILE: @@ -759,7 +797,7 @@ export class InMemoryStorageService extends AbstractStorageService { } } -export async function logStorage(application: Map, profile: Map, workspace: Map, applicationPath: string, profilePath: string, workspacePath: string): Promise { +export async function logStorage(application: Map, applicationShared: Map, profile: Map, workspace: Map, applicationPath: string, applicationSharedPath: string, profilePath: string, workspacePath: string): Promise { const safeParse = (value: string) => { try { return JSON.parse(value); @@ -775,6 +813,13 @@ export async function logStorage(application: Map, profile: Map< applicationItemsParsed.set(key, safeParse(value)); }); + const applicationSharedItems = new Map(); + const applicationSharedItemsParsed = new Map(); + applicationShared.forEach((value, key) => { + applicationSharedItems.set(key, value); + applicationSharedItemsParsed.set(key, safeParse(value)); + }); + const profileItems = new Map(); const profileItemsParsed = new Map(); profile.forEach((value, key) => { @@ -803,6 +848,16 @@ export async function logStorage(application: Map, profile: Map< console.log(applicationItemsParsed); + console.group(`Storage: Application Shared (path: ${applicationSharedPath})`); + const applicationSharedValues: { key: string; value: string }[] = []; + applicationSharedItems.forEach((value, key) => { + applicationSharedValues.push({ key, value }); + }); + console.table(applicationSharedValues); + console.groupEnd(); + + console.log(applicationSharedItemsParsed); + if (applicationPath !== profilePath) { console.group(`Storage: Profile (path: ${profilePath}, profile specific)`); const profileValues: { key: string; value: string }[] = []; diff --git a/src/vs/platform/storage/common/storageIpc.ts b/src/vs/platform/storage/common/storageIpc.ts index 9bcc394353b20..cfd6268582ad4 100644 --- a/src/vs/platform/storage/common/storageIpc.ts +++ b/src/vs/platform/storage/common/storageIpc.ts @@ -30,6 +30,12 @@ export interface IBaseSerializableStorageRequest { */ readonly workspace: ISerializedWorkspaceIdentifier | ISerializedSingleFolderWorkspaceIdentifier | IEmptyWorkspaceIdentifier | undefined; + /** + * Whether this request targets the application shared storage + * that is shared across VS Code and Sessions app. + */ + readonly applicationShared?: boolean; + /** * Additional payload for the request to perform. */ @@ -50,6 +56,10 @@ abstract class BaseStorageDatabaseClient extends Disposable implements IStorageD abstract readonly onDidChangeItemsExternal: Event; + protected get applicationShared(): boolean { + return false; + } + constructor( protected channel: IChannel, protected profile: UriDto | undefined, @@ -59,14 +69,14 @@ abstract class BaseStorageDatabaseClient extends Disposable implements IStorageD } async getItems(): Promise> { - const serializableRequest: IBaseSerializableStorageRequest = { profile: this.profile, workspace: this.workspace }; + const serializableRequest: IBaseSerializableStorageRequest = { profile: this.profile, workspace: this.workspace, applicationShared: this.applicationShared }; const items: Item[] = await this.channel.call('getItems', serializableRequest); return new Map(items); } updateItems(request: IUpdateRequest): Promise { - const serializableRequest: ISerializableUpdateRequest = { profile: this.profile, workspace: this.workspace }; + const serializableRequest: ISerializableUpdateRequest = { profile: this.profile, workspace: this.workspace, applicationShared: this.applicationShared }; if (request.insert) { serializableRequest.insert = Array.from(request.insert.entries()); @@ -80,7 +90,7 @@ abstract class BaseStorageDatabaseClient extends Disposable implements IStorageD } optimize(): Promise { - const serializableRequest: IBaseSerializableStorageRequest = { profile: this.profile, workspace: this.workspace }; + const serializableRequest: IBaseSerializableStorageRequest = { profile: this.profile, workspace: this.workspace, applicationShared: this.applicationShared }; return this.channel.call('optimize', serializableRequest); } @@ -100,7 +110,7 @@ abstract class BaseProfileAwareStorageDatabaseClient extends BaseStorageDatabase } private registerListeners(): void { - this._register(this.channel.listen('onDidChangeStorage', { profile: this.profile })((e: ISerializableItemsChangeEvent) => this.onDidChangeStorage(e))); + this._register(this.channel.listen('onDidChangeStorage', { profile: this.profile, applicationShared: this.applicationShared })((e: ISerializableItemsChangeEvent) => this.onDidChangeStorage(e))); } private onDidChangeStorage(e: ISerializableItemsChangeEvent): void { @@ -129,6 +139,26 @@ export class ApplicationStorageDatabaseClient extends BaseProfileAwareStorageDat } } +export class ApplicationSharedStorageDatabaseClient extends BaseProfileAwareStorageDatabaseClient { + + constructor(channel: IChannel) { + super(channel, undefined); + } + + protected override get applicationShared(): boolean { + return true; + } + + async close(): Promise { + + // The application shared storage database is shared across all instances so + // we do not close it from the window. However we dispose the + // listener for external changes because we no longer interested in it. + + this.dispose(); + } +} + export class ProfileStorageDatabaseClient extends BaseProfileAwareStorageDatabaseClient { async close(): Promise { @@ -170,3 +200,30 @@ export class StorageClient { return this.channel.call('isUsed', serializableRequest); } } + +export class FallbackApplicationStorageDatabaseClient extends Disposable implements IStorageDatabase { + + onDidChangeItemsExternal = Event.None; + + constructor(private readonly channel: IChannel) { + super(); + } + + async getItems(): Promise> { + const serializableRequest: IBaseSerializableStorageRequest = { profile: undefined, workspace: undefined, applicationShared: true }; + const items: Item[] = await this.channel.call('getFallbackApplicationStorageItems', serializableRequest); + return new Map(items); + } + + updateItems(): Promise { + throw new Error('Not supported'); + } + + optimize(): Promise { + throw new Error('Not supported'); + } + + close(): Promise { + throw new Error('Not supported'); + } +} diff --git a/src/vs/platform/storage/common/storageService.ts b/src/vs/platform/storage/common/storageService.ts index 1b6d567fe3e07..9bb5d71cdf8d4 100644 --- a/src/vs/platform/storage/common/storageService.ts +++ b/src/vs/platform/storage/common/storageService.ts @@ -11,14 +11,16 @@ import { IStorage, Storage } from '../../../base/parts/storage/common/storage.js import { IEnvironmentService } from '../../environment/common/environment.js'; import { IRemoteService } from '../../ipc/common/services.js'; import { AbstractStorageService, isProfileUsingDefaultStorage, StorageScope, WillSaveStateReason } from './storage.js'; -import { ApplicationStorageDatabaseClient, ProfileStorageDatabaseClient, WorkspaceStorageDatabaseClient } from './storageIpc.js'; +import { ApplicationStorageDatabaseClient, ApplicationSharedStorageDatabaseClient, ProfileStorageDatabaseClient, WorkspaceStorageDatabaseClient } from './storageIpc.js'; import { isUserDataProfile, IUserDataProfile } from '../../userDataProfile/common/userDataProfile.js'; import { IAnyWorkspaceIdentifier } from '../../workspace/common/workspace.js'; export class RemoteStorageService extends AbstractStorageService { private readonly applicationStorageProfile: IUserDataProfile; - private readonly applicationStorage: IStorage; + protected readonly applicationStorage: IStorage; + + private readonly applicationSharedStorage: IStorage; private profileStorageProfile: IUserDataProfile; private readonly profileStorageDisposables = this._register(new DisposableStore()); @@ -31,13 +33,14 @@ export class RemoteStorageService extends AbstractStorageService { constructor( initialWorkspace: IAnyWorkspaceIdentifier | undefined, initialProfiles: { defaultProfile: IUserDataProfile; currentProfile: IUserDataProfile }, - private readonly remoteService: IRemoteService, + protected readonly remoteService: IRemoteService, private readonly environmentService: IEnvironmentService ) { super(); this.applicationStorageProfile = initialProfiles.defaultProfile; this.applicationStorage = this.createApplicationStorage(); + this.applicationSharedStorage = this.createApplicationSharedStorage(); this.profileStorageProfile = initialProfiles.currentProfile; this.profileStorage = this.createProfileStorage(this.profileStorageProfile); @@ -55,6 +58,15 @@ export class RemoteStorageService extends AbstractStorageService { return applicationStorage; } + protected createApplicationSharedStorage(): IStorage { + const storageDataBaseClient = this._register(new ApplicationSharedStorageDatabaseClient(this.remoteService.getChannel('storage'))); + const applicationSharedStorage = this._register(new Storage(storageDataBaseClient)); + + this._register(applicationSharedStorage.onDidChangeStorage(e => this.emitDidChangeValue(StorageScope.APPLICATION_SHARED, e))); + + return applicationSharedStorage; + } + private createProfileStorage(profile: IUserDataProfile): IStorage { // First clear any previously associated disposables @@ -108,6 +120,7 @@ export class RemoteStorageService extends AbstractStorageService { // Init all storage locations await Promises.settled([ this.applicationStorage.init(), + this.applicationSharedStorage.init(), this.profileStorage.init(), this.workspaceStorage?.init() ?? Promise.resolve() ]); @@ -115,6 +128,8 @@ export class RemoteStorageService extends AbstractStorageService { protected getStorage(scope: StorageScope): IStorage | undefined { switch (scope) { + case StorageScope.APPLICATION_SHARED: + return this.applicationSharedStorage; case StorageScope.APPLICATION: return this.applicationStorage; case StorageScope.PROFILE: @@ -126,6 +141,8 @@ export class RemoteStorageService extends AbstractStorageService { protected getLogDetails(scope: StorageScope): string | undefined { switch (scope) { + case StorageScope.APPLICATION_SHARED: + return joinPath(this.environmentService.appSharedDataHome, 'sharedStorage').with({ scheme: Schemas.file }).fsPath; case StorageScope.APPLICATION: return this.applicationStorageProfile.globalStorageHome.with({ scheme: Schemas.file }).fsPath; case StorageScope.PROFILE: @@ -146,6 +163,7 @@ export class RemoteStorageService extends AbstractStorageService { // Do it await Promises.settled([ this.applicationStorage.close(), + this.applicationSharedStorage.close(), this.profileStorage.close(), this.workspaceStorage?.close() ?? Promise.resolve() ]); diff --git a/src/vs/platform/storage/electron-main/storageIpc.ts b/src/vs/platform/storage/electron-main/storageIpc.ts index 50e72f57610f8..18be14084e244 100644 --- a/src/vs/platform/storage/electron-main/storageIpc.ts +++ b/src/vs/platform/storage/electron-main/storageIpc.ts @@ -9,7 +9,7 @@ import { revive } from '../../../base/common/marshalling.js'; import { IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; import { ILogService } from '../../log/common/log.js'; import { IBaseSerializableStorageRequest, ISerializableItemsChangeEvent, ISerializableUpdateRequest, Key, Value } from '../common/storageIpc.js'; -import { IStorageChangeEvent, IStorageMain } from './storageMain.js'; +import { ApplicationSharedStorageMain, IStorageChangeEvent, IStorageMain } from './storageMain.js'; import { IStorageMainService } from './storageMainService.js'; import { IUserDataProfile } from '../../userDataProfile/common/userDataProfile.js'; import { reviveIdentifier, IAnyWorkspaceIdentifier } from '../../workspace/common/workspace.js'; @@ -19,6 +19,7 @@ export class StorageDatabaseChannel extends Disposable implements IServerChannel private static readonly STORAGE_CHANGE_DEBOUNCE_TIME = 100; private readonly onDidChangeApplicationStorageEmitter = this._register(new Emitter()); + private readonly onDidChangeApplicationSharedStorageEmitter = this._register(new Emitter()); private readonly mapProfileToOnDidChangeProfileStorageEmitter = new Map>(); @@ -29,6 +30,7 @@ export class StorageDatabaseChannel extends Disposable implements IServerChannel super(); this.registerStorageChangeListeners(storageMainService.applicationStorage, this.onDidChangeApplicationStorageEmitter); + this.registerStorageChangeListeners(storageMainService.applicationSharedStorage, this.onDidChangeApplicationSharedStorageEmitter); } //#region Storage Change Events @@ -77,8 +79,12 @@ export class StorageDatabaseChannel extends Disposable implements IServerChannel case 'onDidChangeStorage': { const profile = arg.profile ? revive(arg.profile) : undefined; - // Without profile: application scope + // Without profile: application or application-shared scope if (!profile) { + if (arg.applicationShared) { + return this.onDidChangeApplicationSharedStorageEmitter.event; + } + return this.onDidChangeApplicationStorageEmitter.event; } @@ -103,14 +109,23 @@ export class StorageDatabaseChannel extends Disposable implements IServerChannel async call(_: unknown, command: string, arg: IBaseSerializableStorageRequest): Promise { const profile = arg.profile ? revive(arg.profile) : undefined; const workspace = reviveIdentifier(arg.workspace); + const applicationShared = arg.applicationShared; // Get storage to be ready - const storage = await this.withStorageInitialized(profile, workspace); + const storage = await this.withStorageInitialized(profile, workspace, applicationShared); // handle call switch (command) { case 'getItems': { - return Array.from(storage.items.entries()); + const items = new Map(storage.items); + return Array.from(items.entries()); + } + + case 'getFallbackApplicationStorageItems': { + if (storage instanceof ApplicationSharedStorageMain) { + return Array.from(storage.applicationStorageItems.entries()); + } + return []; } case 'updateItems': { @@ -144,12 +159,14 @@ export class StorageDatabaseChannel extends Disposable implements IServerChannel } } - private async withStorageInitialized(profile: IUserDataProfile | undefined, workspace: IAnyWorkspaceIdentifier | undefined): Promise { + private async withStorageInitialized(profile: IUserDataProfile | undefined, workspace: IAnyWorkspaceIdentifier | undefined, applicationShared?: boolean): Promise { let storage: IStorageMain; if (workspace) { storage = this.storageMainService.workspaceStorage(workspace); } else if (profile) { storage = this.storageMainService.profileStorage(profile); + } else if (applicationShared) { + storage = this.storageMainService.applicationSharedStorage; } else { storage = this.storageMainService.applicationStorage; } @@ -157,7 +174,7 @@ export class StorageDatabaseChannel extends Disposable implements IServerChannel try { await storage.init(); } catch (error) { - this.logService.error(`StorageIPC#init: Unable to init ${workspace ? 'workspace' : profile ? 'profile' : 'application'} storage due to ${error}`); + this.logService.error(`StorageIPC#init: Unable to init ${workspace ? 'workspace' : profile ? 'profile' : applicationShared ? 'application-shared' : 'application'} storage due to ${error}`); } return storage; diff --git a/src/vs/platform/storage/electron-main/storageMain.ts b/src/vs/platform/storage/electron-main/storageMain.ts index 7d343af1f0374..90c8db613ddd2 100644 --- a/src/vs/platform/storage/electron-main/storageMain.ts +++ b/src/vs/platform/storage/electron-main/storageMain.ts @@ -12,8 +12,8 @@ import { join } from '../../../base/common/path.js'; import { StopWatch } from '../../../base/common/stopwatch.js'; import { URI } from '../../../base/common/uri.js'; import { Promises } from '../../../base/node/pfs.js'; -import { InMemoryStorageDatabase, IStorage, Storage, StorageHint, StorageState } from '../../../base/parts/storage/common/storage.js'; -import { ISQLiteStorageDatabaseLoggingOptions, SQLiteStorageDatabase } from '../../../base/parts/storage/node/storage.js'; +import { InMemoryStorageDatabase, IStorage, IStorageDatabase, IStorageItemsChangeEvent, IUpdateRequest, Storage, StorageHint, StorageState, MigratingStorage } from '../../../base/parts/storage/common/storage.js'; +import { ISQLiteStorageDatabaseLoggingOptions, ISQLiteStorageDatabaseOptions, SQLiteStorageDatabase } from '../../../base/parts/storage/node/storage.js'; import { IEnvironmentService } from '../../environment/common/environment.js'; import { IFileService } from '../../files/common/files.js'; import { ILogService, LogLevel } from '../../log/common/log.js'; @@ -22,6 +22,7 @@ import { IUserDataProfile, IUserDataProfilesService } from '../../userDataProfil import { currentSessionDateStorageKey, firstSessionDateStorageKey, lastSessionDateStorageKey } from '../../telemetry/common/telemetry.js'; import { isSingleFolderWorkspaceIdentifier, isWorkspaceIdentifier, IAnyWorkspaceIdentifier } from '../../workspace/common/workspace.js'; import { Schemas } from '../../../base/common/network.js'; +import { ICrossAppIPCMessage, ICrossAppIPCService } from '../../crossAppIpc/electron-main/crossAppIpcService.js'; export interface IStorageMainOptions { @@ -348,6 +349,107 @@ export class ApplicationStorageMain extends BaseProfileAwareStorageMain { } } +export class ApplicationSharedStorageMain extends BaseStorageMain { + + private static readonly STORAGE_NAME = 'state.vscdb'; + + get path(): string | undefined { + if (!this.options.useInMemoryStorage) { + return join(this.storageFolderPath, ApplicationSharedStorageMain.STORAGE_NAME); + } + + return undefined; + } + + private sharedDatabase: SharedSQLiteStorageDatabase | undefined; + + constructor( + private readonly options: IStorageMainOptions, + private readonly storageFolderPath: string, + private readonly applicationStorage: IStorageMain, + logService: ILogService, + fileService: IFileService, + private readonly crossAppIPCService: ICrossAppIPCService, + ) { + super(logService, fileService); + } + + protected async doCreate(): Promise { + const { storageFilePath, wasCreated } = await this.prepareStorageFolder(); + + this.logService.info(`[shared storage] Creating shared storage database at '${storageFilePath}' (wasCreated: ${wasCreated})`); + + this.sharedDatabase = new SharedSQLiteStorageDatabase(storageFilePath, { + logging: this.createLoggingOptions(), + useWAL: true, + busyTimeout: 2000 + }, this.crossAppIPCService, this.logService); + this._register(this.sharedDatabase); + + this.logService.info(`[shared storage] Initializing fallback application storage (type: ${this.applicationStorage instanceof HostApplicationStorageMain ? 'host' : 'local'}, path: ${this.applicationStorage.path ?? 'in-memory'})`); + await this.applicationStorage.init(); + this.logService.info(`[shared storage] Fallback application storage initialized with ${this.applicationStorage.items.size} items`); + + const migratingStorage = this._register(new MigratingStorage(this.sharedDatabase, { hint: wasCreated ? StorageHint.STORAGE_DOES_NOT_EXIST : undefined })); + migratingStorage.setFallbackStorage(this.applicationStorage.storage, this.applicationStorage instanceof HostApplicationStorageMain); + return migratingStorage; + } + + protected override async doInit(storage: IStorage): Promise { + await super.doInit(storage); + + // Mark the shared database as initialized so that + // cross-app IPC messages are processed from now on. + // This must happen after Storage.init() completes to + // avoid processing stale queued messages. + this.sharedDatabase?.setInitialized(); + } + + get applicationStorageItems(): Map { + return this.applicationStorage.items; + } + + private async prepareStorageFolder(): Promise<{ storageFilePath: string; wasCreated: boolean }> { + if (this.options.useInMemoryStorage) { + return { storageFilePath: SQLiteStorageDatabase.IN_MEMORY_PATH, wasCreated: true }; + } + + const storageDatabasePath = join(this.storageFolderPath, ApplicationSharedStorageMain.STORAGE_NAME); + + const storageExists = await Promises.exists(this.storageFolderPath); + if (storageExists) { + return { storageFilePath: storageDatabasePath, wasCreated: false }; + } + + await fs.promises.mkdir(this.storageFolderPath, { recursive: true }); + + return { storageFilePath: storageDatabasePath, wasCreated: true }; + } +} + +export class HostApplicationStorageMain extends BaseStorageMain { + + constructor( + readonly path: string, + logService: ILogService, + fileService: IFileService + ) { + super(logService, fileService); + } + + protected async doCreate(): Promise { + this.logService.info(`[shared storage] Opening host application storage at '${this.path}'`); + try { + const storage = new Storage(new SQLiteStorageDatabase(this.path, { logging: this.createLoggingOptions() })); + return storage; + } catch (error) { + this.logService.error(`[shared storage] Failed to open host application storage at '${this.path}': ${error}`); + throw error; + } + } + +} + export class WorkspaceStorageMain extends BaseStorageMain { private static readonly WORKSPACE_STORAGE_NAME = 'state.vscdb'; @@ -426,6 +528,102 @@ export class WorkspaceStorageMain extends BaseStorageMain { } } +const enum SharedStorageMessageType { + Changed = 'sharedStorage:changed' +} + +interface ISharedStorageChangedMessage extends ICrossAppIPCMessage { + readonly type: SharedStorageMessageType.Changed; + readonly data: { + readonly changed?: [string, string][]; + readonly deleted?: string[]; + }; +} + +/** + * A SQLite storage database wrapper that detects external changes + * via CrossAppIPC. When the sibling app (VS Code or Sessions app) + * writes to the shared storage, it sends an IPC message with the + * changed keys for instant notification. + */ +class SharedSQLiteStorageDatabase extends Disposable implements IStorageDatabase { + + private readonly _onDidChangeItemsExternal = this._register(new Emitter()); + readonly onDidChangeItemsExternal = this._onDidChangeItemsExternal.event; + + private readonly database: SQLiteStorageDatabase; + private initialized = false; + + constructor( + path: string, + options: ISQLiteStorageDatabaseOptions | undefined, + private readonly crossAppIPCService: ICrossAppIPCService, + private readonly logService: ILogService + ) { + super(); + + this.database = new SQLiteStorageDatabase(path, options); + + this.registerListeners(); + } + + private registerListeners(): void { + this._register(this.crossAppIPCService.onDidReceiveMessage(msg => { + if (msg.type !== SharedStorageMessageType.Changed) { + return; + } + + if (!this.initialized) { + this.logService.trace('[shared storage] Ignoring cross-app IPC message received before initialization'); + return; + } + + const { changed, deleted } = (msg as ISharedStorageChangedMessage).data; + this.logService.trace(`[shared storage] Received cross-app IPC change: ${changed?.length ?? 0} changed, ${deleted?.length ?? 0} deleted`); + + this._onDidChangeItemsExternal.fire({ + changed: changed ? new Map(changed) : undefined, + deleted: deleted ? new Set(deleted) : undefined + }); + })); + } + + setInitialized(): void { + this.initialized = true; + } + + async getItems(): Promise> { + const items = await this.database.getItems(); + this.logService.trace(`[shared storage] Initialized with ${items.size} items`); + return items; + } + + async updateItems(request: IUpdateRequest): Promise { + await this.database.updateItems(request); + + const changedCount = request.insert?.size ?? 0; + const deletedCount = request.delete?.size ?? 0; + this.logService.trace(`[shared storage] Sending cross-app IPC change: ${changedCount} changed, ${deletedCount} deleted`); + + // Notify the sibling app via IPC + this.crossAppIPCService.sendMessage({ + type: SharedStorageMessageType.Changed, + data: { + changed: request.insert ? Array.from(request.insert.entries()) : undefined, + deleted: request.delete ? Array.from(request.delete.values()) : undefined + } + }); + } + + async optimize(): Promise { + return this.database.optimize(); + } + + async close(recovery?: () => Map): Promise { + return this.database.close(recovery); + } +} + export class InMemoryStorageMain extends BaseStorageMain { get path(): string | undefined { diff --git a/src/vs/platform/storage/electron-main/storageMainService.ts b/src/vs/platform/storage/electron-main/storageMainService.ts index 1d85c5ab430ae..c766ca7a79d80 100644 --- a/src/vs/platform/storage/electron-main/storageMainService.ts +++ b/src/vs/platform/storage/electron-main/storageMainService.ts @@ -6,19 +6,24 @@ import { URI } from '../../../base/common/uri.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; +import { join } from '../../../base/common/path.js'; import { IStorage } from '../../../base/parts/storage/common/storage.js'; -import { IEnvironmentService } from '../../environment/common/environment.js'; +import { INativeEnvironmentService } from '../../environment/common/environment.js'; import { IFileService } from '../../files/common/files.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { ILifecycleMainService, LifecycleMainPhase, ShutdownReason } from '../../lifecycle/electron-main/lifecycleMainService.js'; import { ILogService } from '../../log/common/log.js'; import { AbstractStorageService, isProfileUsingDefaultStorage, IStorageService, StorageScope, StorageTarget } from '../common/storage.js'; -import { ApplicationStorageMain, ProfileStorageMain, InMemoryStorageMain, IStorageMain, IStorageMainOptions, WorkspaceStorageMain, IStorageChangeEvent } from './storageMain.js'; +import { ApplicationStorageMain, ApplicationSharedStorageMain, ProfileStorageMain, InMemoryStorageMain, IStorageMain, IStorageMainOptions, WorkspaceStorageMain, IStorageChangeEvent, HostApplicationStorageMain } from './storageMain.js'; import { IUserDataProfile, IUserDataProfilesService } from '../../userDataProfile/common/userDataProfile.js'; import { IUserDataProfilesMainService } from '../../userDataProfile/electron-main/userDataProfile.js'; import { IAnyWorkspaceIdentifier } from '../../workspace/common/workspace.js'; import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js'; import { Schemas } from '../../../base/common/network.js'; +import { ICrossAppIPCService } from '../../crossAppIpc/electron-main/crossAppIpcService.js'; +import { IProductService } from '../../product/common/productService.js'; +import { INodeProcess } from '../../../base/common/platform.js'; +import { getUserDataPath } from '../../environment/node/userDataPath.js'; //#region Storage Main Service (intent: make application, profile and workspace storage accessible to windows from main process) @@ -42,6 +47,12 @@ export interface IStorageMainService { */ readonly applicationStorage: IStorageMain; + /** + * Provides access to the application shared storage that is shared + * across VS Code and Agents app. + */ + readonly applicationSharedStorage: IStorageMain; + /** * Emitted whenever data is updated or deleted in profile scoped storage. */ @@ -83,15 +94,18 @@ export class StorageMainService extends Disposable implements IStorageMainServic constructor( @ILogService private readonly logService: ILogService, - @IEnvironmentService private readonly environmentService: IEnvironmentService, + @INativeEnvironmentService private readonly environmentService: INativeEnvironmentService, @IUserDataProfilesMainService private readonly userDataProfilesService: IUserDataProfilesMainService, @ILifecycleMainService private readonly lifecycleMainService: ILifecycleMainService, @IFileService private readonly fileService: IFileService, - @IUriIdentityService private readonly uriIdentityService: IUriIdentityService + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, + @ICrossAppIPCService private readonly crossAppIPCService: ICrossAppIPCService, + @IProductService private readonly productService: IProductService ) { super(); this.applicationStorage = this._register(this.createApplicationStorage()); + this.applicationSharedStorage = this._register(this.createApplicationSharedStorage()); this.registerListeners(); } @@ -109,6 +123,7 @@ export class StorageMainService extends Disposable implements IStorageMainServic await this.lifecycleMainService.when(LifecycleMainPhase.AfterWindowOpen); this.applicationStorage.init(); + this.applicationSharedStorage.init(); })(); this._register(this.lifecycleMainService.onWillLoadWindow(e => { @@ -134,6 +149,9 @@ export class StorageMainService extends Disposable implements IStorageMainServic // Application Storage e.join('applicationStorage', this.applicationStorage.close()); + // Application Shared Storage + e.join('applicationSharedStorage', this.applicationSharedStorage.close()); + // Profile Storage(s) for (const [, profileStorage] of this.mapProfileToStorage) { e.join('profileStorage', profileStorage.close()); @@ -181,6 +199,48 @@ export class StorageMainService extends Disposable implements IStorageMainServic //#endregion + //#region Application Shared Storage + + readonly applicationSharedStorage: IStorageMain; + + private createApplicationSharedStorage(): IStorageMain { + this.logService.info(`StorageMainService: creating application shared storage`); + + const sharedStorageFolderPath = join(this.environmentService.appSharedDataHome.with({ scheme: Schemas.file }).fsPath, 'sharedStorage'); + + // Determine the fallback storage for transparent migration of keys + // from APPLICATION to APPLICATION_SHARED scope: + // In VS Code: reuse the own application storage (keys are local) + let fallbackStorage: IStorageMain = this.applicationStorage; + if (this.environmentService.isBuilt && (process as INodeProcess).isEmbeddedApp) { + // - In the Agents App: create a storage backed by the host (VS Code) + // app's application DB so keys are found even if VS Code hasn't + // migrated them to the shared DB yet. + // We use ProfileStorageMain (not ApplicationStorageMain) to avoid + // writing telemetry state into the host app's DB — this is read-only. + const hostUserDataPath = getUserDataPath(this.environmentService.args, this.productService.quality === 'stable' ? 'Code' : this.productService.quality === 'insider' ? 'Code - Insiders' : 'Code - Exploration'); + const hostApplicationStoragePath = join(hostUserDataPath, 'User', 'globalStorage', 'state.vscdb'); + this.logService.info(`StorageMainService: creating application shared storage with host app fallback at '${hostApplicationStoragePath}'`); + fallbackStorage = this._register(new HostApplicationStorageMain( + hostApplicationStoragePath, + this.logService, + this.fileService + )); + } else { + this.logService.info(`StorageMainService: creating application shared storage with local application storage fallback`); + } + + const applicationSharedStorage = new ApplicationSharedStorageMain(this.getStorageOptions(), sharedStorageFolderPath, fallbackStorage, this.logService, this.fileService, this.crossAppIPCService); + + this._register(Event.once(applicationSharedStorage.onDidCloseStorage)(() => { + this.logService.trace(`StorageMainService: closed application shared storage`); + })); + + return applicationSharedStorage; + } + + //#endregion + //#region Profile Storage private readonly mapProfileToStorage = new Map(); @@ -273,7 +333,7 @@ export class StorageMainService extends Disposable implements IStorageMainServic isUsed(path: string): boolean { const pathUri = URI.file(path); - for (const storage of [this.applicationStorage, ...this.mapProfileToStorage.values(), ...this.mapWorkspaceToStorage.values()]) { + for (const storage of [this.applicationStorage, this.applicationSharedStorage, ...this.mapProfileToStorage.values(), ...this.mapWorkspaceToStorage.values()]) { if (!storage.path) { continue; } @@ -298,6 +358,8 @@ export const IApplicationStorageMainService = createDecorator; - get(key: string, scope: StorageScope.APPLICATION, fallbackValue: string): string; - get(key: string, scope: StorageScope.APPLICATION, fallbackValue?: string): string | undefined; + get(key: string, scope: ApplicationStorageScope, fallbackValue: string): string; + get(key: string, scope: ApplicationStorageScope, fallbackValue?: string): string | undefined; - getBoolean(key: string, scope: StorageScope.APPLICATION, fallbackValue: boolean): boolean; - getBoolean(key: string, scope: StorageScope.APPLICATION, fallbackValue?: boolean): boolean | undefined; + getBoolean(key: string, scope: ApplicationStorageScope, fallbackValue: boolean): boolean; + getBoolean(key: string, scope: ApplicationStorageScope, fallbackValue?: boolean): boolean | undefined; - getNumber(key: string, scope: StorageScope.APPLICATION, fallbackValue: number): number; - getNumber(key: string, scope: StorageScope.APPLICATION, fallbackValue?: number): number | undefined; + getNumber(key: string, scope: ApplicationStorageScope, fallbackValue: number): number; + getNumber(key: string, scope: ApplicationStorageScope, fallbackValue?: number): number | undefined; - store(key: string, value: string | boolean | number | undefined | null, scope: StorageScope.APPLICATION, target: StorageTarget): void; + store(key: string, value: string | boolean | number | undefined | null, scope: ApplicationStorageScope, target: StorageTarget): void; - remove(key: string, scope: StorageScope.APPLICATION): void; + remove(key: string, scope: ApplicationStorageScope): void; - keys(scope: StorageScope.APPLICATION, target: StorageTarget): string[]; + keys(scope: ApplicationStorageScope, target: StorageTarget): string[]; switch(): never; - isNew(scope: StorageScope.APPLICATION): boolean; + isNew(scope: ApplicationStorageScope): boolean; } export class ApplicationStorageMainService extends AbstractStorageService implements IApplicationStorageMainService { @@ -345,7 +407,10 @@ export class ApplicationStorageMainService extends AbstractStorageService implem ) { super(); - this.whenReady = this.storageMainService.applicationStorage.whenInit; + this.whenReady = Promise.all([ + this.storageMainService.applicationStorage.whenInit, + this.storageMainService.applicationSharedStorage.whenInit + ]).then(() => undefined); } protected doInitialize(): Promise { @@ -353,7 +418,10 @@ export class ApplicationStorageMainService extends AbstractStorageService implem // application storage is being initialized as part // of the first window opening, so we do not trigger // it here but can join it - return this.storageMainService.applicationStorage.whenInit; + return Promise.all([ + this.storageMainService.applicationStorage.whenInit, + this.storageMainService.applicationSharedStorage.whenInit + ]).then(() => undefined); } protected getStorage(scope: StorageScope): IStorage | undefined { @@ -361,6 +429,10 @@ export class ApplicationStorageMainService extends AbstractStorageService implem return this.storageMainService.applicationStorage.storage; } + if (scope === StorageScope.APPLICATION_SHARED) { + return this.storageMainService.applicationSharedStorage.storage; + } + return undefined; // any other scope is unsupported from main process } @@ -369,6 +441,10 @@ export class ApplicationStorageMainService extends AbstractStorageService implem return this.userDataProfilesService.defaultProfile.globalStorageHome.with({ scheme: Schemas.file }).fsPath; } + if (scope === StorageScope.APPLICATION_SHARED) { + return this.storageMainService.applicationSharedStorage.path; + } + return undefined; // any other scope is unsupported from main process } 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 b1bb35b06024d..37e68988fcb95 100644 --- a/src/vs/platform/storage/test/electron-main/storageMainService.test.ts +++ b/src/vs/platform/storage/test/electron-main/storageMainService.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { notStrictEqual, strictEqual } from 'assert'; +import { notStrictEqual, ok, strictEqual } from 'assert'; import { Schemas } from '../../../../base/common/network.js'; import { joinPath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; @@ -26,6 +26,8 @@ import { UserDataProfilesMainService } from '../../../userDataProfile/electron-m import { TestLifecycleMainService } from '../../../test/electron-main/workbenchTestServices.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { ICrossAppIPCMessage, ICrossAppIPCService } from '../../../crossAppIpc/electron-main/crossAppIpcService.js'; suite('StorageMainService', function () { @@ -33,6 +35,19 @@ suite('StorageMainService', function () { const productService: IProductService = { _serviceBrand: undefined, ...product }; + const nullCrossAppIPCService: ICrossAppIPCService = { + _serviceBrand: undefined, + isSupported: false, + initialized: false, + connected: false, + isServer: false, + onDidConnect: Event.None, + onDidDisconnect: Event.None, + onDidReceiveMessage: Event.None, + sendMessage: () => { }, + initialize: () => { } + }; + const inMemoryProfileRoot = URI.file('/location').with({ scheme: Schemas.inMemory }); const inMemoryProfile: IUserDataProfile = { id: 'id', @@ -116,7 +131,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)); + 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, productService)); disposables.add(testStorageService.applicationStorage); @@ -136,6 +151,12 @@ suite('StorageMainService', function () { return testStorage(storageMainService.profileStorage(profile), StorageScope.PROFILE); }); + test('basics (application shared)', function () { + const storageMainService = createStorageService(); + + return testStorage(storageMainService.applicationSharedStorage, StorageScope.APPLICATION_SHARED); + }); + test('basics (workspace)', function () { const workspace = { id: generateUuid() }; const storageMainService = createStorageService(); @@ -260,5 +281,105 @@ suite('StorageMainService', function () { strictEqual(didCloseWorkspaceStorage, true); }); + test('application shared storage receives cross-app IPC changes', async function () { + const onDidReceiveMessage = disposables.add(new Emitter()); + const sentMessages: ICrossAppIPCMessage[] = []; + const crossAppIPCService: ICrossAppIPCService = { + _serviceBrand: undefined, + isSupported: true, + initialized: true, + connected: true, + isServer: false, + onDidConnect: Event.None, + onDidDisconnect: Event.None, + onDidReceiveMessage: onDidReceiveMessage.event, + sendMessage: (msg) => sentMessages.push(msg), + initialize: () => { } + }; + + 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, productService)); + + const storage = storageMainService.applicationSharedStorage; + disposables.add(storage); + await storage.init(); + + // Verify that receiving a cross-app IPC message triggers a change event + let changeEvent: IStorageChangeEvent | undefined; + disposables.add(storage.onDidChangeStorage(e => { changeEvent = e; })); + + onDidReceiveMessage.fire({ + type: 'sharedStorage:changed', + data: { + changed: [['externalKey', 'externalValue']], + deleted: undefined + } + }); + + strictEqual(changeEvent?.key, 'externalKey'); + strictEqual(storage.get('externalKey'), 'externalValue'); + + // Verify that storing a value sends a cross-app IPC message + // (close flushes pending writes which triggers sendMessage) + const messagesBefore = sentMessages.length; + storage.set('testKey', 'testValue'); + await storage.close(); + ok(sentMessages.length > messagesBefore); + strictEqual(sentMessages[sentMessages.length - 1].type, 'sharedStorage:changed'); + + // Verify that messages received before init are ignored + const onDidReceiveMessage2 = disposables.add(new Emitter()); + const crossAppIPCService2: ICrossAppIPCService = { + ...crossAppIPCService, + 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, productService)); + + const storage2 = storageMainService2.applicationSharedStorage; + disposables.add(storage2); + + let preInitChangeReceived = false; + disposables.add(storage2.onDidChangeStorage(() => { preInitChangeReceived = true; })); + + // Fire message before init + onDidReceiveMessage2.fire({ + type: 'sharedStorage:changed', + data: { changed: [['preInitKey', 'preInitValue']] } + }); + + strictEqual(preInitChangeReceived, false); + + // Now init and verify subsequent messages work + await storage2.init(); + + onDidReceiveMessage2.fire({ + type: 'sharedStorage:changed', + data: { changed: [['postInitKey', 'postInitValue']] } + }); + + strictEqual(storage2.get('postInitKey'), 'postInitValue'); + + await storage2.close(); + }); + + test('application shared storage closed onWillShutdown', async function () { + const lifecycleMainService = new TestLifecycleMainService(); + const storageMainService = createStorageService(lifecycleMainService); + + const applicationSharedStorage = storageMainService.applicationSharedStorage; + let didCloseApplicationSharedStorage = false; + disposables.add(applicationSharedStorage.onDidCloseStorage(() => { + didCloseApplicationSharedStorage = true; + })); + + await applicationSharedStorage.init(); + await lifecycleMainService.fireOnWillShutdown(); + + strictEqual(didCloseApplicationSharedStorage, true); + }); + ensureNoDisposablesAreLeakedInTestSuite(); }); diff --git a/src/vs/platform/update/common/update.config.contribution.ts b/src/vs/platform/update/common/update.config.contribution.ts index 6061e15bd1f76..9d1a83d0dd085 100644 --- a/src/vs/platform/update/common/update.config.contribution.ts +++ b/src/vs/platform/update/common/update.config.contribution.ts @@ -80,10 +80,10 @@ configurationRegistry.registerConfiguration({ }, 'update.showPostInstallInfo': { type: 'boolean', - default: true, + default: false, scope: ConfigurationScope.APPLICATION, - description: localize('showPostInstallInfo', "Show update information tooltip in the title bar after a new version is installed."), - included: false, + description: localize('showPostInstallInfo', "Show a post-install update tooltip in the title bar instead of opening the release notes editor."), + tags: ['usesOnlineServices'] } } }); diff --git a/src/vs/platform/userDataProfile/common/userDataProfileStorageService.ts b/src/vs/platform/userDataProfile/common/userDataProfileStorageService.ts index 14af8ede3c080..15a7a2e5b9283 100644 --- a/src/vs/platform/userDataProfile/common/userDataProfileStorageService.ts +++ b/src/vs/platform/userDataProfile/common/userDataProfileStorageService.ts @@ -26,6 +26,7 @@ export interface IProfileStorageChanges { export interface IStorageValue { readonly value: string | undefined; readonly target: StorageTarget; + readonly scope?: StorageScope; } export const IUserDataProfileStorageService = createDecorator('IUserDataProfileStorageService'); @@ -48,8 +49,9 @@ export interface IUserDataProfileStorageService { * @param profile The profile to which the data has to be written to * @param data Data that has to be updated * @param target Storage target of the data + * @param scope Storage scope of the data (defaults to PROFILE) */ - updateStorageData(profile: IUserDataProfile, data: Map, target: StorageTarget): Promise; + updateStorageData(profile: IUserDataProfile, data: Map, target: StorageTarget, scope?: StorageScope): Promise; /** * Calls a function with a storage service scoped to given profile. @@ -76,11 +78,11 @@ export abstract class AbstractUserDataProfileStorageService extends Disposable i } async readStorageData(profile: IUserDataProfile): Promise> { - return this.withProfileScopedStorageService(profile, async storageService => this.getItems(storageService)); + return this.withProfileScopedStorageService(profile, async storageService => this.getItems(storageService, profile)); } - async updateStorageData(profile: IUserDataProfile, data: Map, target: StorageTarget): Promise { - return this.withProfileScopedStorageService(profile, async storageService => this.writeItems(storageService, data, target)); + async updateStorageData(profile: IUserDataProfile, data: Map, target: StorageTarget, scope = StorageScope.PROFILE): Promise { + return this.withProfileScopedStorageService(profile, async storageService => this.writeItems(storageService, data, target, scope)); } async withProfileScopedStorageService(profile: IUserDataProfile, fn: (storageService: IStorageService) => Promise): Promise { @@ -115,20 +117,24 @@ export abstract class AbstractUserDataProfileStorageService extends Disposable i } } - private getItems(storageService: IStorageService): Map { + private getItems(storageService: IStorageService, profile: IUserDataProfile): Map { const result = new Map(); - const populate = (target: StorageTarget) => { - for (const key of storageService.keys(StorageScope.PROFILE, target)) { - result.set(key, { value: storageService.get(key, StorageScope.PROFILE), target }); + const populate = (scope: StorageScope, target: StorageTarget) => { + for (const key of storageService.keys(scope, target)) { + result.set(key, { value: storageService.get(key, scope), target, scope }); } }; - populate(StorageTarget.USER); - populate(StorageTarget.MACHINE); + populate(StorageScope.PROFILE, StorageTarget.USER); + populate(StorageScope.PROFILE, StorageTarget.MACHINE); + if (profile.isDefault) { + populate(StorageScope.APPLICATION_SHARED, StorageTarget.USER); + populate(StorageScope.APPLICATION_SHARED, StorageTarget.MACHINE); + } return result; } - private writeItems(storageService: IStorageService, items: Map, target: StorageTarget): void { - storageService.storeAll(Array.from(items.entries()).map(([key, value]) => ({ key, value, scope: StorageScope.PROFILE, target })), true); + private writeItems(storageService: IStorageService, items: Map, target: StorageTarget, scope = StorageScope.PROFILE): void { + storageService.storeAll(Array.from(items.entries()).map(([key, value]) => ({ key, value, scope, target })), true); } protected abstract createStorageDatabase(profile: IUserDataProfile): Promise; diff --git a/src/vs/platform/userDataProfile/test/common/userDataProfileStorageService.test.ts b/src/vs/platform/userDataProfile/test/common/userDataProfileStorageService.test.ts index 3e17c72ba91dd..4f9b5b204d6c4 100644 --- a/src/vs/platform/userDataProfile/test/common/userDataProfileStorageService.test.ts +++ b/src/vs/platform/userDataProfile/test/common/userDataProfileStorageService.test.ts @@ -73,7 +73,7 @@ suite('ProfileStorageService', () => { const actual = await testObject.readStorageData(profile); assert.strictEqual(actual.size, 1); - assert.deepStrictEqual(actual.get('foo'), { 'value': 'bar', 'target': StorageTarget.USER }); + assert.deepStrictEqual(actual.get('foo'), { 'value': 'bar', 'target': StorageTarget.USER, 'scope': 0 }); })); test('write in empty storage', () => runWithFakedTimers({ useFakeTimers: true }, async () => { diff --git a/src/vs/platform/userDataSync/common/globalStateSync.ts b/src/vs/platform/userDataSync/common/globalStateSync.ts index e37893825af17..540b3ea4d8867 100644 --- a/src/vs/platform/userDataSync/common/globalStateSync.ts +++ b/src/vs/platform/userDataSync/common/globalStateSync.ts @@ -339,7 +339,7 @@ export class LocalGlobalStateProvider { const storageData = await this.userDataProfileStorageService.readStorageData(profile); for (const [key, value] of storageData) { if (value.value && value.target === StorageTarget.USER) { - storage[key] = { version: 1, value: value.value }; + storage[key] = { version: 1, value: value.value, scope: value.scope }; } } return { storage }; @@ -360,7 +360,8 @@ export class LocalGlobalStateProvider { async writeLocalGlobalState({ added, removed, updated }: { added: IStringDictionary; updated: IStringDictionary; removed: string[] }, profile: IUserDataProfile): Promise { const syncResourceLogLabel = getSyncResourceLogLabel(SyncResource.GlobalState, profile); const argv: IStringDictionary = {}; - const updatedStorage = new Map(); + const updatedProfileStorage = new Map(); + const updatedSharedStorage = profile.isDefault ? new Map() : undefined; const storageData = await this.userDataProfileStorageService.readStorageData(profile); const handleUpdatedStorage = (keys: string[], storage?: IStringDictionary): void => { for (const key of keys) { @@ -371,11 +372,13 @@ export class LocalGlobalStateProvider { if (storage) { const storageValue = storage[key]; if (storageValue.value !== storageData.get(key)?.value) { - updatedStorage.set(key, storageValue.value); + const targetMap = updatedSharedStorage && storageValue.scope === StorageScope.APPLICATION_SHARED ? updatedSharedStorage : updatedProfileStorage; + targetMap.set(key, storageValue.value); } } else { if (storageData.get(key) !== undefined) { - updatedStorage.set(key, undefined); + const targetMap = updatedSharedStorage && storageData.get(key)?.scope === StorageScope.APPLICATION_SHARED ? updatedSharedStorage : updatedProfileStorage; + targetMap.set(key, undefined); } } } @@ -399,10 +402,16 @@ export class LocalGlobalStateProvider { this.logService.info(`${syncResourceLogLabel}: Updated locale`); } - if (updatedStorage.size) { + if (updatedProfileStorage.size) { this.logService.trace(`${syncResourceLogLabel}: Updating global state...`); - await this.userDataProfileStorageService.updateStorageData(profile, updatedStorage, StorageTarget.USER); - this.logService.info(`${syncResourceLogLabel}: Updated global state`, [...updatedStorage.keys()]); + await this.userDataProfileStorageService.updateStorageData(profile, updatedProfileStorage, StorageTarget.USER); + this.logService.info(`${syncResourceLogLabel}: Updated global state`, [...updatedProfileStorage.keys()]); + } + + if (updatedSharedStorage?.size) { + this.logService.trace(`${syncResourceLogLabel}: Updating application shared state...`); + await this.userDataProfileStorageService.updateStorageData(profile, updatedSharedStorage, StorageTarget.USER, StorageScope.APPLICATION_SHARED); + this.logService.info(`${syncResourceLogLabel}: Updated application shared state`, [...updatedSharedStorage.keys()]); } } } @@ -428,13 +437,19 @@ export class GlobalStateInitializer extends AbstractInitializer { } const argv: IStringDictionary = {}; + const isDefaultProfile = this.storageService.hasScope(this.userDataProfilesService.defaultProfile); const storage: IStringDictionary = {}; for (const key of Object.keys(remoteGlobalState.storage)) { if (key.startsWith(argvStoragePrefx)) { argv[key.substring(argvStoragePrefx.length)] = remoteGlobalState.storage[key].value; } else { - if (this.storageService.get(key, StorageScope.PROFILE) === undefined) { - storage[key] = remoteGlobalState.storage[key].value; + const isSharedScope = remoteGlobalState.storage[key].scope === StorageScope.APPLICATION_SHARED; + if (isSharedScope && !isDefaultProfile) { + continue; // Skip APPLICATION_SHARED keys for non-default profiles + } + const scope = isSharedScope ? StorageScope.APPLICATION_SHARED : StorageScope.PROFILE; + if (this.storageService.get(key, scope) === undefined) { + storage[key] = { value: remoteGlobalState.storage[key].value, scope }; } } } @@ -454,7 +469,7 @@ export class GlobalStateInitializer extends AbstractInitializer { if (Object.keys(storage).length) { const storageEntries: Array = []; for (const key of Object.keys(storage)) { - storageEntries.push({ key, value: storage[key], scope: StorageScope.PROFILE, target: StorageTarget.USER }); + storageEntries.push({ key, value: storage[key].value, scope: storage[key].scope, target: StorageTarget.USER }); } this.storageService.storeAll(storageEntries, true); } diff --git a/src/vs/platform/userDataSync/common/userDataSync.ts b/src/vs/platform/userDataSync/common/userDataSync.ts index c929e49dea257..f0d3070fbf339 100644 --- a/src/vs/platform/userDataSync/common/userDataSync.ts +++ b/src/vs/platform/userDataSync/common/userDataSync.ts @@ -23,6 +23,7 @@ import { createDecorator } from '../../instantiation/common/instantiation.js'; import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../jsonschemas/common/jsonContributionRegistry.js'; import { ILogService } from '../../log/common/log.js'; import { Registry } from '../../registry/common/platform.js'; +import { StorageScope } from '../../storage/common/storage.js'; import { IUserDataProfile, UseDefaultProfileFlags } from '../../userDataProfile/common/userDataProfile.js'; import { IUserDataSyncMachine } from './userDataSyncMachines.js'; @@ -423,6 +424,7 @@ export interface IRemoteSyncExtension { export interface IStorageValue { version: number; value: string; + scope?: StorageScope; } export interface IGlobalState { diff --git a/src/vs/platform/userDataSync/test/common/globalStateSync.test.ts b/src/vs/platform/userDataSync/test/common/globalStateSync.test.ts index 78b93b440c284..2d47fb2b14ca8 100644 --- a/src/vs/platform/userDataSync/test/common/globalStateSync.test.ts +++ b/src/vs/platform/userDataSync/test/common/globalStateSync.test.ts @@ -82,7 +82,7 @@ suite('GlobalStateSync', () => { const remoteUserData = await testObject.getRemoteUserData(null); assert.deepStrictEqual(lastSyncUserData!.ref, remoteUserData.ref); assert.deepStrictEqual(lastSyncUserData!.syncData, remoteUserData.syncData); - assert.deepStrictEqual(JSON.parse(lastSyncUserData!.syncData!.content).storage, { 'a': { version: 1, value: 'value1' } }); + assert.deepStrictEqual(JSON.parse(lastSyncUserData!.syncData!.content).storage, { 'a': { version: 1, value: 'value1', scope: 0 } }); })); test('first time sync - outgoing to server (no state)', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -97,7 +97,7 @@ suite('GlobalStateSync', () => { const { content } = await testClient.read(testObject.resource); assert.ok(content !== null); const actual = parseGlobalState(content); - assert.deepStrictEqual(actual.storage, { 'globalState.argv.locale': { version: 1, value: 'en' }, 'a': { version: 1, value: 'value1' } }); + assert.deepStrictEqual(actual.storage, { 'globalState.argv.locale': { version: 1, value: 'en' }, 'a': { version: 1, value: 'value1', scope: 0 } }); })); test('first time sync - incoming from server (no state)', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -128,7 +128,7 @@ suite('GlobalStateSync', () => { const { content } = await testClient.read(testObject.resource); assert.ok(content !== null); const actual = parseGlobalState(content); - assert.deepStrictEqual(actual.storage, { 'a': { version: 1, value: 'value1' }, 'b': { version: 1, value: 'value2' } }); + assert.deepStrictEqual(actual.storage, { 'a': { version: 1, value: 'value1', scope: 0 }, 'b': { version: 1, value: 'value2', scope: 0 } }); })); test('first time sync when storage exists - has conflicts', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -146,7 +146,7 @@ suite('GlobalStateSync', () => { const { content } = await testClient.read(testObject.resource); assert.ok(content !== null); const actual = parseGlobalState(content); - assert.deepStrictEqual(actual.storage, { 'a': { version: 1, value: 'value1' } }); + assert.deepStrictEqual(actual.storage, { 'a': { version: 1, value: 'value1', scope: 0 } }); })); test('sync adding a storage value', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -164,7 +164,7 @@ suite('GlobalStateSync', () => { const { content } = await testClient.read(testObject.resource); assert.ok(content !== null); const actual = parseGlobalState(content); - assert.deepStrictEqual(actual.storage, { 'a': { version: 1, value: 'value1' }, 'b': { version: 1, value: 'value2' } }); + assert.deepStrictEqual(actual.storage, { 'a': { version: 1, value: 'value1', scope: 0 }, 'b': { version: 1, value: 'value2', scope: 0 } }); })); test('sync updating a storage value', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -181,7 +181,7 @@ suite('GlobalStateSync', () => { const { content } = await testClient.read(testObject.resource); assert.ok(content !== null); const actual = parseGlobalState(content); - assert.deepStrictEqual(actual.storage, { 'a': { version: 1, value: 'value2' } }); + assert.deepStrictEqual(actual.storage, { 'a': { version: 1, value: 'value2', scope: 0 } }); })); test('sync removing a storage value', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -200,7 +200,7 @@ suite('GlobalStateSync', () => { const { content } = await testClient.read(testObject.resource); assert.ok(content !== null); const actual = parseGlobalState(content); - assert.deepStrictEqual(actual.storage, { 'a': { version: 1, value: 'value1' } }); + assert.deepStrictEqual(actual.storage, { 'a': { version: 1, value: 'value1', scope: 0 } }); })); test('sync profile state', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -221,7 +221,7 @@ suite('GlobalStateSync', () => { const { content } = await testClient.read(testObject.resource, '1'); assert.ok(content !== null); const actual = parseGlobalState(content); - assert.deepStrictEqual(actual.storage, { 'a': { version: 1, value: 'value1' } }); + assert.deepStrictEqual(actual.storage, { 'a': { version: 1, value: 'value1', scope: 0 } }); })); function parseGlobalState(content: string): IGlobalState { diff --git a/src/vs/platform/workspaces/electron-main/workspacesHistoryMainService.ts b/src/vs/platform/workspaces/electron-main/workspacesHistoryMainService.ts index 29bbbcce877ab..68f71f1cf2f4d 100644 --- a/src/vs/platform/workspaces/electron-main/workspacesHistoryMainService.ts +++ b/src/vs/platform/workspaces/electron-main/workspacesHistoryMainService.ts @@ -252,7 +252,7 @@ export class WorkspacesHistoryMainService extends Disposable implements IWorkspa let storedRecentlyOpened: object | undefined = undefined; // First try with storage service - const storedRecentlyOpenedRaw = this.applicationStorageMainService.get(WorkspacesHistoryMainService.RECENTLY_OPENED_STORAGE_KEY, StorageScope.APPLICATION); + const storedRecentlyOpenedRaw = this.applicationStorageMainService.get(WorkspacesHistoryMainService.RECENTLY_OPENED_STORAGE_KEY, StorageScope.APPLICATION_SHARED); if (typeof storedRecentlyOpenedRaw === 'string') { try { storedRecentlyOpened = JSON.parse(storedRecentlyOpenedRaw); @@ -269,8 +269,8 @@ export class WorkspacesHistoryMainService extends Disposable implements IWorkspa // Wait for global storage to be ready await this.applicationStorageMainService.whenReady; - // Store in global storage (but do not sync since this is mainly local paths) - this.applicationStorageMainService.store(WorkspacesHistoryMainService.RECENTLY_OPENED_STORAGE_KEY, JSON.stringify(toStoreData(recent)), StorageScope.APPLICATION, StorageTarget.MACHINE); + // Store in application shared storage (but do not sync since this is mainly local paths) + this.applicationStorageMainService.store(WorkspacesHistoryMainService.RECENTLY_OPENED_STORAGE_KEY, JSON.stringify(toStoreData(recent)), StorageScope.APPLICATION_SHARED, StorageTarget.MACHINE); } private location(recent: IRecent): URI { diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index ccc341a089bb7..a6801e423ba39 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -172,13 +172,15 @@ This structure places the sidebar at the root level spanning the full window hei | Part | Default Size | |------|--------------| | Sidebar | 300px width | -| Auxiliary Bar | 300px width | +| Auxiliary Bar | 380px width | | Chat Bar | Remaining space | | Panel | 300px height | | Titlebar | Determined by `minimumHeight` (~30px) | The sessions sidebar can be resized down to a minimum width of 170px (desktop) or 270px (web, sized to fit the titlebar's left toolbar which includes the host filter combo). +The sessions auxiliary bar can generally be resized down to 270px. When the main editor part is visible (i.e. any editor is open in the main editor area adjacent to the auxiliary bar), the sash no longer snaps it closed; the titlebar toggle action still hides and shows the auxiliary bar as before. This behavior is automatic and applies to all editor types without requiring an explicit allowlist. + ### 4.3 Editor Modal The main editor part is created hidden (`display:none`) and remains hidden for the default sessions experience. Flows that explicitly open or restore an editor into the main editor part can reveal it, and modal editor opens do not change the visibility of an already visible main editor. Editors without an explicit main-part target still open in the `ModalEditorPart` overlay via the standard `createModalEditorPart()` mechanism. @@ -207,7 +209,7 @@ The setting `workbench.editor.useModal` is an enum with three values: - `'some'`: Certain editors (e.g. Settings, Keyboard Shortcuts) may open in a modal overlay when requested via `MODAL_GROUP` - `'all'`: All editors open in a modal overlay (used by agent sessions window) -The sessions default configuration also sets `workbench.notifications.position` to `'top-right'` so toast notifications anchor in the top-right corner of the sessions window without changing the default notification placement in the regular workbench. +The sessions default configuration also sets `workbench.notifications.position` to `'bottom-right'` so notifications anchor in the bottom-right corner of the sessions window without changing the default notification placement in the regular workbench. The sessions-specific stylesheet adjusts both notification center and toast offsets to `15px` from the bottom/right or bottom/left edges, and to `top: 40px; right: 15px;` for the top-right placement. Because the shared workbench notification controllers also compute a top-right inline offset for custom titlebar windows, the sessions workbench reapplies its fixed `40px` top offset after those controllers run so the sessions-only placement stays stable. --- @@ -379,7 +381,7 @@ The Agent Sessions workbench uses specialized part implementations that extend t |---------|----------------|---------------------| | Activity Bar integration | Full support | No activity bar; account widget in the titlebar | | Composite bar position | Configurable (top/bottom/title/hidden) | Fixed: Title | -| Composite bar visibility | Configurable | Sidebar: hidden (`shouldShowCompositeBar()` returns `false`); ChatBar: hidden; Auxiliary Bar & Panel: visible | +| Composite bar visibility | Configurable | Sidebar: hidden (`shouldShowCompositeBar()` returns `false`); ChatBar: hidden; Auxiliary Bar & Panel: visible. Separately, the internal chat tab strip shown inside the Chat Bar preserves each chat title's original casing instead of forcing per-word capitalization via CSS. | | Auto-hide support | Configurable | Disabled | | Configuration listening | Many settings | Minimal | | Context menu actions | Full set | Simplified | @@ -659,6 +661,12 @@ interface IPartVisibilityState { | Date | Change | |------|--------| +| 2026-04-22 | Added sessions-only toast offset overrides so notification toasts now use `right: 15px` in the default bottom-right placement and `left: 15px` in the bottom-left placement, matching the notification center spacing. | +| 2026-04-22 | Added a sessions-workbench notification offset override so the shared notification controllers no longer push top-right notifications down to `42px`; sessions now reapply a fixed `40px` top offset for top-right notification center/toast placement. | +| 2026-04-22 | Generalized the auxiliary bar snap-close prevention to trigger whenever the main editor part is visible (any editor type), so the behavior now applies automatically without maintaining an editor-type allowlist. | +| 2026-04-22 | Updated the sessions auxiliary bar sizing rules so attached diff editors and integrated browser editors keep the normal 270px auxiliary-bar minimum width while disabling sash snap-to-close in that state, and the titlebar toggle continues to hide/show the secondary sidebar normally. | +| 2026-04-21 | Updated the sessions chat composite bar tabs to preserve each chat title's original casing instead of applying per-word capitalization. | +| 2026-04-21 | Moved the sessions-only default notification placement to bottom-right and documented the sessions-specific notification center offsets: `15px` from the bottom/right or bottom/left edges, and `top: 40px; right: 15px;` for top-right placement. | | 2026-04-17 | Added a subtle 1px titlebar-token border around the sessions account widget's GitHub profile image, including the inactive-window variant, and documented the avatar chrome in the layout spec. | | 2026-04-16 | Softened the experimental sessions shell gradient by reducing the accent tint mix strength across the shared default, light-theme, and dark-theme variants so the primary color reads more subtly behind the workbench chrome. | | 2026-04-16 | Updated the layout visual representation to show the editor part in the top-right row and mark it as hidden by default. | diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 60f72545d8ea2..22f41207b4f89 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -69,6 +69,42 @@ display: none; } +.monaco-workbench.agent-sessions-workbench > .notifications-center { + right: 15px; + bottom: 15px; +} + +.monaco-workbench.agent-sessions-workbench > .notifications-toasts { + right: 15px; + bottom: 15px; +} + +.monaco-workbench.agent-sessions-workbench.nostatusbar > .notifications-center { + bottom: 15px; +} + +.monaco-workbench.agent-sessions-workbench.nostatusbar > .notifications-toasts { + bottom: 15px; +} + +.monaco-workbench.agent-sessions-workbench > .notifications-center.bottom-left { + right: auto; + left: 15px; + bottom: 15px; +} + +.monaco-workbench.agent-sessions-workbench > .notifications-toasts.bottom-left { + right: auto; + left: 15px; + bottom: 15px; +} + +.monaco-workbench.agent-sessions-workbench > .notifications-center.top-right { + top: 40px; + right: 15px; + bottom: auto; +} + .agent-sessions-workbench.experimental-shell-gradient-background > .monaco-grid-view { position: relative; z-index: 1; diff --git a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts index 836962b6ca4b9..37798abc20b79 100644 --- a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts +++ b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts @@ -35,6 +35,7 @@ import { IBaseActionViewItemOptions } from '../../../base/browser/ui/actionbar/a import { getFlatContextMenuActions } from '../../../platform/actions/browser/menuEntryActionViewItem.js'; import { IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; import { Extensions } from '../../../workbench/browser/panecomposite.js'; +import { mainWindow } from '../../../base/browser/window.js'; /** * Auxiliary bar part specifically for agent sessions workbench. @@ -55,6 +56,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { // Action ID for run script - defined here to avoid layering issues private static readonly RUN_SCRIPT_ACTION_ID = 'workbench.action.agentSessions.runScript'; private static readonly RUN_SCRIPT_DROPDOWN_MENU_ID = MenuId.for('AgentSessionsRunScriptDropdown'); + private static readonly DEFAULT_MINIMUM_WIDTH = 270; // Run script dropdown management private readonly _runScriptDropdown = this._register(new MutableDisposable()); @@ -62,10 +64,15 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { private readonly _runScriptMenuListener = this._register(new MutableDisposable()); // Sessions-specific auxiliary bar dimensions (intentionally not tied to the sessions SidebarPart values) - override readonly minimumWidth: number = 270; + override get minimumWidth(): number { + return AuxiliaryBarPart.DEFAULT_MINIMUM_WIDTH; + } override readonly maximumWidth: number = Number.POSITIVE_INFINITY; override readonly minimumHeight: number = 0; override readonly maximumHeight: number = Number.POSITIVE_INFINITY; + override get snap(): boolean { + return this.hasAttachedEditorRequiringSidebarSpace() ? false : super.snap; + } get preferredHeight(): number | undefined { return this.layoutService.mainContainerDimension.height * 0.4; @@ -133,6 +140,11 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { menuService, ); + this._register(this.layoutService.onDidChangePartVisibility(e => { + if (e.partId === Parts.AUXILIARYBAR_PART || e.partId === Parts.EDITOR_PART) { + this._onDidChange.fire(undefined); + } + })); } override create(parent: HTMLElement): void { @@ -252,6 +264,11 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { } } + private hasAttachedEditorRequiringSidebarSpace(): boolean { + return this.layoutService.isVisible(Parts.AUXILIARYBAR_PART) + && this.layoutService.isVisible(Parts.EDITOR_PART, mainWindow); + } + private fillExtraContextMenuActions(_actions: IAction[]): void { } protected shouldShowCompositeBar(): boolean { diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index 7b40db913d8b1..257e50c333199 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -26,7 +26,7 @@ display: none; } -/* Base tab: capitalize text + pill padding — mirrors auxiliarybar action-label */ +/* Base tab: preserve chat title casing while keeping the same pill treatment */ .chat-composite-bar-tab { display: flex; align-items: center; @@ -35,7 +35,6 @@ cursor: pointer; white-space: nowrap; color: var(--chat-tab-inactive-foreground); - text-transform: capitalize; font-weight: 500; font-size: 12px; line-height: 22px; diff --git a/src/vs/sessions/browser/parts/media/titlebarpart.css b/src/vs/sessions/browser/parts/media/titlebarpart.css index 41edffffa3c87..f3e6ee55a0e65 100644 --- a/src/vs/sessions/browser/parts/media/titlebarpart.css +++ b/src/vs/sessions/browser/parts/media/titlebarpart.css @@ -78,6 +78,17 @@ border-radius: var(--vscode-cornerRadius-medium); } +/* Secondary sidebar toggle uses icon variants for toggle state — no background needed */ +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-session-actions-container .action-label.codicon-agent-secondary-sidebar-toggle-open.checked, +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-session-actions-container .action-label.codicon-agent-secondary-sidebar-toggle-closed.checked { + background: none; +} + +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-session-actions-container .action-label.codicon-agent-secondary-sidebar-toggle-open.checked:hover, +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-session-actions-container .action-label.codicon-agent-secondary-sidebar-toggle-closed.checked:hover { + background: var(--vscode-toolbar-hoverBackground); +} + .monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container .monaco-action-bar .action-item:not(.disabled) .codicon { color: var(--vscode-icon-foreground); } diff --git a/src/vs/sessions/browser/parts/menubar.contribution.ts b/src/vs/sessions/browser/parts/menubar.contribution.ts new file mode 100644 index 0000000000000..b42ea4dbb572c --- /dev/null +++ b/src/vs/sessions/browser/parts/menubar.contribution.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../nls.js'; +import { MenuId, MenuRegistry } from '../../../platform/actions/common/actions.js'; +import { IsMacNativeContext } from '../../../platform/contextkey/common/contextkeys.js'; + +MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { + submenu: MenuId.MenubarFileMenu, + title: { + value: 'File', + original: 'File', + mnemonicTitle: localize({ key: 'mFile', comment: ['&& denotes a mnemonic'] }, "&&File"), + }, + order: 1 +}); + +MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { + submenu: MenuId.MenubarEditMenu, + title: { + value: 'Edit', + original: 'Edit', + mnemonicTitle: localize({ key: 'mEdit', comment: ['&& denotes a mnemonic'] }, "&&Edit") + }, + order: 2 +}); + +MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { + submenu: MenuId.MenubarViewMenu, + title: { + value: 'View', + original: 'View', + mnemonicTitle: localize({ key: 'mView', comment: ['&& denotes a mnemonic'] }, "&&View") + }, + order: 4 +}); + +MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { + submenu: MenuId.MenubarTerminalMenu, + title: { + value: 'Terminal', + original: 'Terminal', + mnemonicTitle: localize({ key: 'mTerminal', comment: ['&& denotes a mnemonic'] }, "&&Terminal") + }, + order: 7 +}); + +MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { + submenu: MenuId.MenubarHelpMenu, + title: { + value: 'Help', + original: 'Help', + mnemonicTitle: localize({ key: 'mHelp', comment: ['&& denotes a mnemonic'] }, "&&Help") + }, + order: 8 +}); + +MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { + submenu: MenuId.MenubarPreferencesMenu, + title: { + value: 'Preferences', + original: 'Preferences', + mnemonicTitle: localize({ key: 'mPreferences', comment: ['&& denotes a mnemonic'] }, "&&Preferences") + }, + when: IsMacNativeContext, + order: 9 +}); diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 6306bae337ba8..e487f96cfaa1e 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -7,7 +7,7 @@ import '../../workbench/browser/style.js'; import './media/style.css'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../base/common/lifecycle.js'; import { Emitter, Event, setGlobalLeakWarningThreshold } from '../../base/common/event.js'; -import { getActiveDocument, getActiveElement, getClientArea, getWindowId, getWindows, IDimension, isAncestorUsingFlowTo, size, Dimension, runWhenWindowIdle } from '../../base/browser/dom.js'; +import { getActiveDocument, getActiveElement, getClientArea, getWindowId, getWindows, IDimension, isAncestorUsingFlowTo, isHTMLElement, size, Dimension, runWhenWindowIdle } from '../../base/browser/dom.js'; import { DeferredPromise, RunOnceScheduler } from '../../base/common/async.js'; import { isFullscreen, onDidChangeFullscreen, isChrome, isFirefox, isSafari } from '../../base/browser/browser.js'; import { mark } from '../../base/common/performance.js'; @@ -64,6 +64,11 @@ import { TitleService } from './parts/titlebarPart.js'; import { SessionsExperimentalSendButtonGradientSettingId, SessionsExperimentalShellGradientBackgroundSettingId } from '../common/configuration.js'; import { IContextKeyService } from '../../platform/contextkey/common/contextkey.js'; import { EditorMaximizedContext } from '../common/contextkeys.js'; +import { + NotificationsPosition, + NotificationsSettings, + getNotificationsPosition +} from '../../workbench/common/notifications.js'; //#region Workbench Options @@ -596,13 +601,17 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.createEditorPart(); // Notification Handlers - this.createNotificationsHandlers(instantiationService, notificationService); + this.createNotificationsHandlers(instantiationService, notificationService, configurationService); // Add Workbench to DOM this.parent.appendChild(this.mainContainer); } - private createNotificationsHandlers(instantiationService: IInstantiationService, notificationService: NotificationService): void { + private createNotificationsHandlers( + instantiationService: IInstantiationService, + notificationService: NotificationService, + configurationService: IConfigurationService + ): void { // Instantiate Notification components const notificationsCenter = this._register(instantiationService.createInstance(NotificationsCenter, this.mainContainer, notificationService.model)); const notificationsToasts = this._register(instantiationService.createInstance(NotificationsToasts, this.mainContainer, notificationService.model)); @@ -625,12 +634,56 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Register notification accessible view AccessibleViewRegistry.register(new NotificationAccessibleView()); + // The shared notification controllers apply a top-right inline offset based on the + // default workbench custom titlebar height. The sessions workbench has its own + // fixed chrome, so re-apply the sessions-specific top-right offset after they run. + this.registerSessionsNotificationOffsets(configurationService, notificationsCenter, notificationsToasts); + // Register with Layout this.registerNotifications({ - onDidChangeNotificationsVisibility: Event.map(Event.any(notificationsToasts.onDidChangeVisibility, notificationsCenter.onDidChangeVisibility), () => notificationsToasts.isVisible || notificationsCenter.isVisible) + onDidChangeNotificationsVisibility: Event.map( + Event.any(notificationsToasts.onDidChangeVisibility, notificationsCenter.onDidChangeVisibility), + () => notificationsToasts.isVisible || notificationsCenter.isVisible + ) }); } + private registerSessionsNotificationOffsets( + configurationService: IConfigurationService, + notificationsCenter: NotificationsCenter, + notificationsToasts: NotificationsToasts + ): void { + const applySessionsNotificationOffsets = () => { + const position = getNotificationsPosition(configurationService); + const notificationsCenterContainer = this.getWorkbenchChildByClassName('notifications-center'); + const notificationsToastsContainer = this.getWorkbenchChildByClassName('notifications-toasts'); + + if (position === NotificationsPosition.TOP_RIGHT) { + notificationsCenterContainer?.style.setProperty('top', '40px'); + notificationsToastsContainer?.style.setProperty('top', '40px'); + } + }; + + this._register(this.onDidLayoutMainContainer(() => applySessionsNotificationOffsets())); + this._register(notificationsCenter.onDidChangeVisibility(() => applySessionsNotificationOffsets())); + this._register(notificationsToasts.onDidChangeVisibility(() => applySessionsNotificationOffsets())); + this._register(configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(NotificationsSettings.NOTIFICATIONS_POSITION)) { + applySessionsNotificationOffsets(); + } + })); + } + + private getWorkbenchChildByClassName(className: string): HTMLElement | undefined { + for (const child of this.mainContainer.children) { + if (isHTMLElement(child) && child.classList.contains(className)) { + return child; + } + } + + return undefined; + } + private createPartContainer(id: string, role: string, classes: string[]): HTMLElement { const part = document.createElement('div'); part.classList.add('part', ...classes); diff --git a/src/vs/sessions/common/agentHostSessionWorkspace.ts b/src/vs/sessions/common/agentHostSessionWorkspace.ts index bd0298f98d632..af6f0350253c0 100644 --- a/src/vs/sessions/common/agentHostSessionWorkspace.ts +++ b/src/vs/sessions/common/agentHostSessionWorkspace.ts @@ -18,6 +18,7 @@ export interface IAgentHostSessionWorkspaceOptions { readonly providerLabel?: string; readonly fallbackIcon: ThemeIcon; readonly requiresWorkspaceTrust: boolean; + readonly description?: string; } export function agentHostSessionWorkspaceKey(workspace: ISessionWorkspace | undefined): string | undefined { @@ -30,6 +31,7 @@ export function buildAgentHostSessionWorkspace(project: IAgentHostSessionProject const repositoryWorkingDirectory = extUri.isEqual(workingDirectory, project.uri) ? undefined : workingDirectory; return { label: options.providerLabel ? `${project.displayName} [${options.providerLabel}]` : project.displayName, + description: options.description, icon: Codicon.repo, repositories: [{ uri: project.uri, workingDirectory: repositoryWorkingDirectory, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }], requiresWorkspaceTrust: options.requiresWorkspaceTrust, @@ -43,6 +45,7 @@ export function buildAgentHostSessionWorkspace(project: IAgentHostSessionProject const folderName = basename(workingDirectory) || workingDirectory.path; return { label: options.providerLabel ? `${folderName} [${options.providerLabel}]` : folderName, + description: options.description, icon: options.fallbackIcon, repositories: [{ uri: workingDirectory, workingDirectory: undefined, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }], requiresWorkspaceTrust: options.requiresWorkspaceTrust, diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index 11192582acecc..58862cd264b0f 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -5,8 +5,9 @@ import { Event } from '../../base/common/event.js'; import { IObservable } from '../../base/common/observable.js'; +import { equals } from '../../base/common/objects.js'; import { RemoteAgentHostConnectionStatus } from '../../platform/agentHost/common/remoteAgentHostService.js'; -import { IResolveSessionConfigResult, ISessionConfigValueItem } from '../../platform/agentHost/common/state/protocol/commands.js'; +import { ResolveSessionConfigResult, SessionConfigValueItem } from '../../platform/agentHost/common/state/protocol/commands.js'; import { ISessionsProvider } from '../services/sessions/common/sessionsProvider.js'; /** @@ -41,13 +42,28 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider { /** Fires when dynamic configuration for a session changes. */ readonly onDidChangeSessionConfig: Event; /** Returns the last resolved dynamic configuration for a session. */ - getSessionConfig(sessionId: string): IResolveSessionConfigResult | undefined; + getSessionConfig(sessionId: string): ResolveSessionConfigResult | undefined; /** Sets one dynamic configuration property and re-resolves the schema. */ - setSessionConfigValue(sessionId: string, property: string, value: string): Promise; + setSessionConfigValue(sessionId: string, property: string, value: unknown): Promise; + /** + * Replaces the full set of running-session config values atomically. + * + * Dispatches a single `session/configChanged` action with replace + * semantics. Only user-editable properties (`sessionMutable: true` and + * not `readOnly`) are actually replaced from the caller-supplied values — + * for every other property the current value is carried through, so + * non-mutable / read-only properties (e.g. `isolation`, `branch`) can + * never be altered through this API even if included in the input. + * Unknown keys (no schema entry) are ignored. + * + * No-op for pre-creation (new) sessions — use {@link setSessionConfigValue} + * there since the schema is still being resolved. + */ + replaceSessionConfig(sessionId: string, values: Record): Promise; /** Returns dynamic completions for a configuration property. */ - getSessionConfigCompletions(sessionId: string, property: string, query?: string): Promise; + getSessionConfigCompletions(sessionId: string, property: string, query?: string): Promise; /** Returns the resolved config that should be sent to createSession. */ - getCreateSessionConfig(sessionId: string): Record | undefined; + getCreateSessionConfig(sessionId: string): Record | undefined; /** Clears dynamic configuration state for an abandoned new session. */ clearSessionConfig(sessionId: string): void; } @@ -64,21 +80,22 @@ export function isAgentHostProvider(provider: ISessionsProvider): provider is IA } /** - * Shallow structural equality for resolved session configs. Returns true when - * both inputs have the same value-key set with identical string values and - * the same set of schema property keys with identical (by-identity) property - * objects. Schema property objects are compared by identity since they - * originate from the same protocol snapshot in the providers that use this - * helper. + * Structural equality for resolved session configs. Returns true when both + * inputs have the same value-key set with deep-equal values and the same set + * of schema property keys with identical (by-identity) property objects. + * Schema property objects are compared by identity since they originate from + * the same protocol snapshot in the providers that use this helper. Values + * are deep-compared via {@link equals} so non-string entries (e.g. permission + * objects) compare correctly. */ -export function resolvedConfigsEqual(a: IResolveSessionConfigResult, b: IResolveSessionConfigResult): boolean { +export function resolvedConfigsEqual(a: ResolveSessionConfigResult, b: ResolveSessionConfigResult): boolean { const aValueKeys = Object.keys(a.values); const bValueKeys = Object.keys(b.values); if (aValueKeys.length !== bValueKeys.length) { return false; } for (const key of aValueKeys) { - if (a.values[key] !== b.values[key]) { + if (!equals(a.values[key], b.values[key])) { return false; } } @@ -98,20 +115,51 @@ export function resolvedConfigsEqual(a: IResolveSessionConfigResult, b: IResolve /** Known auto-approve config values. */ const AUTO_APPROVE_ENUM = ['default', 'autoApprove', 'autopilot']; +type MutableConfigSchemaItem = + | { type: 'string'; title: string; sessionMutable: true; enum: string[] } + | { type: 'number'; title: string; sessionMutable: true } + | { type: 'boolean'; title: string; sessionMutable: true } + | { type: 'array'; title: string; sessionMutable: true } + | { type: 'object'; title: string; sessionMutable: true }; + +function buildMutableConfigSchemaItem(key: string, value: unknown): MutableConfigSchemaItem | undefined { + if (typeof value === 'string') { + return { + type: 'string', + title: key, + sessionMutable: true, + enum: key === 'autoApprove' ? AUTO_APPROVE_ENUM : [value], + }; + } + if (typeof value === 'number') { + return { type: 'number', title: key, sessionMutable: true }; + } + if (typeof value === 'boolean') { + return { type: 'boolean', title: key, sessionMutable: true }; + } + if (Array.isArray(value)) { + return { type: 'array', title: key, sessionMutable: true }; + } + if (value && typeof value === 'object') { + return { type: 'object', title: key, sessionMutable: true }; + } + return undefined; +} + /** * Builds a minimal session-mutable config schema from changed values. * Used when a restored session receives a ConfigChanged action before - * the full schema has been hydrated. + * the full schema has been hydrated. Properties whose value type isn't + * representable in the config schema (e.g. `null`, `undefined`) are + * omitted. */ -export function buildMutableConfigSchema(config: Record): Record { - const properties: Record = {}; +export function buildMutableConfigSchema(config: Record): Record { + const properties: Record = {}; for (const key of Object.keys(config)) { - properties[key] = { - type: 'string', - title: key, - sessionMutable: true, - enum: key === 'autoApprove' ? AUTO_APPROVE_ENUM : [config[key]], - }; + const property = buildMutableConfigSchemaItem(key, config[key]); + if (property) { + properties[key] = property; + } } return properties; } diff --git a/src/vs/sessions/common/sessionConfig.ts b/src/vs/sessions/common/sessionConfig.ts index 4919e411dbcea..cc8590541a1da 100644 --- a/src/vs/sessions/common/sessionConfig.ts +++ b/src/vs/sessions/common/sessionConfig.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { IResolveSessionConfigResult } from '../../platform/agentHost/common/state/protocol/commands.js'; +import type { ResolveSessionConfigResult } from '../../platform/agentHost/common/state/protocol/commands.js'; -export function isSessionConfigComplete(config: IResolveSessionConfigResult): boolean { +export function isSessionConfigComplete(config: ResolveSessionConfigResult): boolean { return (config.schema.required ?? []).every(property => config.values[property] !== undefined); } diff --git a/src/vs/sessions/contrib/agentHost/browser/agentSessionSettings.contribution.ts b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettings.contribution.ts new file mode 100644 index 0000000000000..657595bed09d1 --- /dev/null +++ b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettings.contribution.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { agentSessionSettingsUri, AGENT_SESSION_SETTINGS_SCHEME, AgentSessionSettingsFileSystemProvider, AgentSessionSettingsSchemaRegistrar } from './agentSessionSettingsFileSystemProvider.js'; + +/** + * Registers the {@link AgentSessionSettingsFileSystemProvider} with the + * {@link IFileService} and contributes the "Open Session Settings" action. + */ +class AgentSessionSettingsContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.agentSessionSettingsContribution'; + + constructor( + @IFileService fileService: IFileService, + @IInstantiationService instantiationService: IInstantiationService, + @ILabelService labelService: ILabelService, + ) { + super(); + + const schemaRegistrar = this._register(instantiationService.createInstance(AgentSessionSettingsSchemaRegistrar)); + const provider = this._register(instantiationService.createInstance(AgentSessionSettingsFileSystemProvider, schemaRegistrar)); + this._register(fileService.registerProvider(AGENT_SESSION_SETTINGS_SCHEME, provider)); + + this._register(labelService.registerFormatter({ + scheme: AGENT_SESSION_SETTINGS_SCHEME, + formatting: { + label: localize('agentSessionSettings.label', "Session Settings"), + separator: '/', + }, + })); + } +} + +registerWorkbenchContribution2(AgentSessionSettingsContribution.ID, AgentSessionSettingsContribution, WorkbenchPhase.AfterRestored); + +registerAction2(class OpenSessionSettingsAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.openSessionSettings', + title: localize2('openSessionSettings', "Open Session Settings"), + menu: [{ + id: SessionItemContextMenuId, + group: '2_settings', + order: 1, + when: ContextKeyExpr.regex(ChatSessionProviderIdContext.key, /^(local-agent-host|agenthost-)/), + }] + }); + } + 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 = agentSessionSettingsUri(session); + await editorService.openEditor({ resource, options: { pinned: true } }); + } +}); diff --git a/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts new file mode 100644 index 0000000000000..2f9b0f901fb43 --- /dev/null +++ b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts @@ -0,0 +1,522 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; +import { ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js'; + +/** Scheme for the synthetic agent-host session settings files. */ +export const AGENT_SESSION_SETTINGS_SCHEME = 'agent-session-settings'; + +/** + * Build the URI used to open the settings file for an agent-host session. + * + * 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} + * without having to look the session up on the provider. + */ +export function agentSessionSettingsUri(session: ISession): URI { + // `resource.path` already starts with `/`, so splice it between the scheme and the `.jsonc` suffix. + return URI.from({ + scheme: AGENT_SESSION_SETTINGS_SCHEME, + authority: session.providerId, + path: `/${session.resource.scheme}${session.resource.path}.jsonc`, + }); +} + +interface IParsedSettingsUri { + readonly providerId: string; + /** Reconstructed {@link ISession.sessionId}. */ + readonly sessionId: string; +} + +function parseSettingsUri(uri: URI): IParsedSettingsUri | undefined { + if (uri.scheme !== AGENT_SESSION_SETTINGS_SCHEME) { + return undefined; + } + const providerId = uri.authority; + if (!providerId) { + return undefined; + } + // Path: /{resourceScheme}/{rawId}.jsonc + const path = uri.path.startsWith('/') ? uri.path.substring(1) : uri.path; + const firstSlash = path.indexOf('/'); + if (firstSlash <= 0) { + return undefined; + } + const resourceScheme = path.substring(0, firstSlash); + let rest = path.substring(firstSlash); // includes leading '/' + const lastDot = rest.lastIndexOf('.'); + if (lastDot > 0) { + rest = rest.substring(0, lastDot); + } + if (!resourceScheme || rest === '/') { + return undefined; + } + const resource = URI.from({ scheme: resourceScheme, path: rest }); + return { providerId, sessionId: toSessionId(providerId, resource) }; +} + +/** + * 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`; +} + +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'); +} + +/** + * 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; + + private readonly _onDidChangeFile = this._register(new Emitter()); + readonly onDidChangeFile = this._onDidChangeFile.event; + + constructor( + private readonly _schemaRegistrar: AgentSessionSettingsSchemaRegistrar, + @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + } + + 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 }]); + } + }); + } + + 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, + }; + } + + async readdir(): Promise<[string, FileType][]> { + throw createFileSystemProviderError('readdir not supported', FileSystemProviderErrorCode.NoPermissions); + } + + 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); + 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, + ); + } + + 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); + } + + 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; + } + + 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 }; + } +} + +/** + * 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: false, + }; + if (required.length > 0) { + result.required = required; + } + return result; +} + +/** + * Keeps per-session JSON schemas registered on the + * {@link IJSONContributionRegistry} 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 { + + private readonly _schemaRegistry = Registry.as(JSONExtensions.JSONContribution); + + /** Per-provider subscriptions (session listeners, config listeners). */ + private readonly _providerSubscriptions = this._register(new DisposableMap()); + + /** Per-session registered-schema disposables, keyed by the settings URI string. */ + private readonly _sessionSchemas = this._register(new DisposableMap()); + + /** + * 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(); + + constructor( + @ISessionsProvidersService private 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._onProviderRemoved(provider); + } + })); + } + + private _onProviderAdded(provider: ISessionsProvider): void { + if (!isAgentHostProvider(provider)) { + return; + } + 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); + } + })); + + 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); + } + })); + + 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); + } +} diff --git a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts index 8dec1c3b0b710..38703790de8ad 100644 --- a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -8,15 +8,16 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { equals } from '../../../../base/common/objects.js'; import { constObservable, derived, IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { AgentSession, IAgentConnection, IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js'; -import { IResolveSessionConfigResult } from '../../../../platform/agentHost/common/state/protocol/commands.js'; +import { ResolveSessionConfigResult } from '../../../../platform/agentHost/common/state/protocol/commands.js'; import { NotificationType } from '../../../../platform/agentHost/common/state/protocol/notifications.js'; -import type { IFileEdit, IModelSelection, IRootState, ISessionConfigPropertySchema, ISessionState, ISessionSummary } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import type { FileEdit, ModelSelection, RootState, SessionState, SessionSummary } 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'; @@ -28,7 +29,7 @@ import { diffsEqual, diffsToChanges, mapProtocolStatus } from './agentHostDiffs. import { buildMutableConfigSchema, IAgentHostSessionsProvider, resolvedConfigsEqual } from '../../../common/agentHostSessionsProvider.js'; import { agentHostSessionWorkspaceKey } from '../../../common/agentHostSessionWorkspace.js'; import { isSessionConfigComplete } from '../../../common/sessionConfig.js'; -import { IChat, IGitHubInfo, ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, SessionStatus } from '../../../services/sessions/common/session.js'; +import { IChat, IGitHubInfo, ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, SessionStatus, toSessionId } from '../../../services/sessions/common/session.js'; import { ISendRequestOptions, ISessionChangeEvent } from '../../../services/sessions/common/sessionsProvider.js'; // ============================================================================ @@ -71,7 +72,7 @@ export class AgentHostSessionAdapter implements ISession { readonly status: ISettableObservable; readonly changes = observableValue('changes', []); readonly modelId: ISettableObservable; - modelSelection: IModelSelection | undefined; + modelSelection: ModelSelection | undefined; readonly mode = observableValue<{ readonly id: string; readonly kind: string } | undefined>('mode', undefined); readonly loading: IObservable; readonly isArchived = observableValue('isArchived', false); @@ -100,7 +101,7 @@ export class AgentHostSessionAdapter implements ISession { } this.agentProvider = agentProvider; this.resource = URI.from({ scheme: resourceScheme, path: `/${rawId}` }); - this.sessionId = `${providerId}:${this.resource.toString()}`; + this.sessionId = toSessionId(providerId, this.resource); this.providerId = providerId; this.sessionType = logicalSessionType; this.icon = _options.icon; @@ -266,12 +267,12 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement protected _selectedModelId: string | undefined; protected readonly _newSessionWorkspaces = new Map(); - protected readonly _newSessionConfigs = new Map(); + protected readonly _newSessionConfigs = new Map(); protected readonly _newSessionAgentProviders = new Map(); protected readonly _newSessionConfigRequests = new Map(); - /** Config for running sessions (session-mutable properties only), keyed by session ID. */ - protected readonly _runningSessionConfigs = new Map(); + /** Full resolved config (schema + values) for running sessions, keyed by session ID. */ + protected readonly _runningSessionConfigs = new Map(); /** * Lazy session-state subscriptions used to seed {@link _runningSessionConfigs} @@ -343,7 +344,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement * host's root state, firing {@link onDidChangeSessionTypes} only if the * id/label set actually changed. */ - protected _syncSessionTypesFromRootState(rootState: IRootState): void { + protected _syncSessionTypesFromRootState(rootState: RootState): void { const next = rootState.agents.map((agent): ISessionType => ({ id: agent.provider, label: this._formatSessionTypeLabel(agent.displayName?.trim() || agent.provider), @@ -358,7 +359,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._onDidChangeSessionTypes.fire(); } - abstract resolveWorkspace(repositoryUri: URI): ISessionWorkspace; + abstract resolveWorkspace(repositoryUri: URI): ISessionWorkspace | undefined; /** Optional event fired when the underlying connection is lost; used to short-circuit `_waitForNewSession`. */ protected get onConnectionLost(): Event { return Event.None; } @@ -427,6 +428,9 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._validateBeforeCreate(sessionType); const workspace = this.resolveWorkspace(workspaceUri); + if (!workspace) { + throw new Error(`Cannot resolve workspace for URI: ${workspaceUri.toString()}`); + } return this._createNewSessionForType(workspace, sessionType); } @@ -504,10 +508,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // -- Dynamic session config ---------------------------------------------- - getSessionConfig(sessionId: string): IResolveSessionConfigResult | undefined { + getSessionConfig(sessionId: string): ResolveSessionConfigResult | undefined { // New-session config wins (during pre-creation flow). Otherwise lazily // subscribe to the session's state so the running picker can seed its - // schema/values from the AHP `ISessionState.config` snapshot for sessions + // schema/values from the AHP `SessionState.config` snapshot for sessions // that weren't created in this window. const newSessionConfig = this._newSessionConfigs.get(sessionId); if (newSessionConfig) { @@ -517,7 +521,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return this._runningSessionConfigs.get(sessionId); } - async setSessionConfigValue(sessionId: string, property: string, value: string): Promise { + async setSessionConfigValue(sessionId: string, property: string, value: unknown): Promise { // New session (pre-creation): re-resolve the full config schema const workingDirectory = this._newSessionWorkspaces.get(sessionId); if (workingDirectory) { @@ -556,6 +560,55 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } } + async replaceSessionConfig(sessionId: string, values: Record): Promise { + const runningConfig = this._runningSessionConfigs.get(sessionId); + const connection = this.connection; + if (!runningConfig || !connection) { + return; + } + + // Build the outgoing payload: for every known property, prefer the + // caller-supplied value if the property is user-editable + // (`sessionMutable: true` and not `readOnly`), otherwise force the + // current value through. This guarantees replace semantics never + // alter a non-editable property even if the caller included it. + 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)) { + nextValues[key] = values[key]; + } else if (Object.hasOwn(runningConfig.values, key)) { + nextValues[key] = runningConfig.values[key]; + } + } + // Unknown keys from the caller are ignored (no schema entry). + + // Skip the dispatch entirely when nothing meaningful changes. + if (equals(nextValues, runningConfig.values)) { + return; + } + + // Update local cache optimistically (full replace). + this._runningSessionConfigs.set(sessionId, { + ...runningConfig, + values: nextValues, + }); + this._onDidChangeSessionConfig.fire(sessionId); + + // Dispatch to the agent host with replace semantics. + const rawId = this._rawIdFromChatId(sessionId); + const cached = rawId ? this._sessionCache.get(rawId) : undefined; + if (cached && rawId) { + const action = { + type: ActionType.SessionConfigChanged as const, + session: AgentSession.uri(cached.agentProvider, rawId).toString(), + config: nextValues, + replace: true, + }; + connection.dispatch(action); + } + } + async getSessionConfigCompletions(sessionId: string, property: string, query?: string) { const workingDirectory = this._newSessionWorkspaces.get(sessionId); const connection = this.connection; @@ -572,7 +625,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return result.items; } - getCreateSessionConfig(sessionId: string): Record | undefined { + getCreateSessionConfig(sessionId: string): Record | undefined { return this._newSessionConfigs.get(sessionId)?.values; } @@ -746,7 +799,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement try { const committedSession = await this._waitForNewSession(existingKeys); if (committedSession) { - this._preserveSessionMutableConfig(chatId, committedSession.sessionId); + this._preserveNewSessionConfig(chatId, committedSession.sessionId); this._currentNewSession = undefined; this._currentNewSessionModelId = undefined; this._currentNewSessionLoading = undefined; @@ -774,7 +827,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // -- Session config plumbing --------------------------------------------- - private async _resolveSessionConfig(sessionId: string, agentProvider: string, workingDirectory: URI, config: Record | undefined): Promise { + private async _resolveSessionConfig(sessionId: string, agentProvider: string, workingDirectory: URI, config: Record | undefined): Promise { const connection = this.connection; if (!connection) { this._setNewSessionLoading(sessionId, false); @@ -812,28 +865,20 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement /** * When a session transitions from untitled (new) to committed (running), - * preserve the session-mutable config properties so they can be changed - * during the running session. + * carry over the full resolved config (schema + values) so consumers like + * the session-settings JSONC editor can round-trip non-mutable values + * (`isolation`, `branch`, …) through a replace dispatch. Mutable-vs-readonly + * behavior is still driven off the per-property `sessionMutable` flag. */ - private _preserveSessionMutableConfig(oldSessionId: string, newSessionId: string): void { + private _preserveNewSessionConfig(oldSessionId: string, newSessionId: string): void { const config = this._newSessionConfigs.get(oldSessionId); if (!config) { return; } - const mutableProperties: IResolveSessionConfigResult['schema']['properties'] = {}; - const mutableValues: Record = {}; - for (const [key, propSchema] of Object.entries(config.schema.properties)) { - if (propSchema.sessionMutable) { - mutableProperties[key] = propSchema; - if (Object.hasOwn(config.values, key)) { - mutableValues[key] = config.values[key]; - } - } - } - if (Object.keys(mutableProperties).length > 0) { + if (Object.keys(config.schema.properties).length > 0) { this._runningSessionConfigs.set(newSessionId, { - schema: { type: 'object', properties: mutableProperties }, - values: mutableValues, + schema: { type: 'object', properties: { ...config.schema.properties } }, + values: { ...config.values }, }); } } @@ -866,7 +911,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement /** * Lazily acquire a session-state subscription for `sessionId` so that - * `_runningSessionConfigs` is seeded from the AHP `ISessionState.config` + * `_runningSessionConfigs` is seeded from the AHP `SessionState.config` * snapshot. Safe to call repeatedly — no-op once a subscription exists. * * The subscription is reference-counted by {@link IAgentConnection.getSubscription}, @@ -903,33 +948,23 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } /** - * Filter `state.config` to session-mutable properties and update - * {@link _runningSessionConfigs} if changed. No-op if the seeded value is - * structurally equal to the existing entry to avoid spurious + * Seed {@link _runningSessionConfigs} from the AHP `SessionState.config` + * snapshot. Keeps the full schema + values (including non-mutable ones) + * so consumers like the JSONC settings editor can round-trip all values + * through a replace dispatch. No-op if structurally equal to avoid spurious * `onDidChangeSessionConfig` fires. */ - private _seedRunningConfigFromState(sessionId: string, state: ISessionState): void { + private _seedRunningConfigFromState(sessionId: string, state: SessionState): void { const stateConfig = state.config; if (!stateConfig) { return; } - const properties: Record = {}; - const values: Record = {}; - for (const [key, propSchema] of Object.entries(stateConfig.schema.properties)) { - if (!propSchema.sessionMutable) { - continue; - } - properties[key] = propSchema; - if (Object.hasOwn(stateConfig.values, key)) { - values[key] = stateConfig.values[key]; - } - } - if (Object.keys(properties).length === 0) { + if (Object.keys(stateConfig.schema.properties).length === 0) { return; } - const seeded: IResolveSessionConfigResult = { - schema: { type: 'object', properties }, - values, + const seeded: ResolveSessionConfigResult = { + schema: { type: 'object', properties: { ...stateConfig.schema.properties } }, + values: { ...stateConfig.values }, }; const existing = this._runningSessionConfigs.get(sessionId); if (existing && resolvedConfigsEqual(existing, seeded)) { @@ -1051,14 +1086,14 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } else if (e.action.type === ActionType.SessionIsDoneChanged && isSessionAction(e.action)) { this._handleIsDoneChanged(e.action.session, e.action.isDone); } else if (e.action.type === ActionType.SessionConfigChanged && isSessionAction(e.action)) { - this._handleConfigChanged(e.action.session, e.action.config); + this._handleConfigChanged(e.action.session, e.action.config, e.action.replace === true); } else if (e.action.type === ActionType.SessionDiffsChanged && isSessionAction(e.action)) { this._handleDiffsChanged(e.action.session, e.action.diffs); } })); } - private _handleSessionAdded(summary: ISessionSummary): void { + private _handleSessionAdded(summary: SessionSummary): void { const sessionUri = URI.parse(summary.resource); const rawId = AgentSession.id(sessionUri); if (this._sessionCache.has(rawId)) { @@ -1104,7 +1139,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } } - private _handleModelChanged(session: string, model: IModelSelection): void { + private _handleModelChanged(session: string, model: ModelSelection): void { const rawId = AgentSession.id(session); const cached = this._sessionCache.get(rawId); if (cached) { @@ -1135,7 +1170,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } } - private _handleDiffsChanged(session: string, diffs: IFileEdit[]): void { + private _handleDiffsChanged(session: string, diffs: FileEdit[]): void { const rawId = AgentSession.id(session); const cached = this._sessionCache.get(rawId); if (cached) { @@ -1144,7 +1179,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } } - private _handleSessionSummaryChanged(session: string, changes: Partial): void { + private _handleSessionSummaryChanged(session: string, changes: Partial): void { const rawId = AgentSession.id(session); const cached = this._sessionCache.get(rawId); if (!cached) { @@ -1179,7 +1214,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } } - private _handleConfigChanged(session: string, config: Record): void { + private _handleConfigChanged(session: string, config: Record, replace: boolean): void { const rawId = AgentSession.id(session); const cached = this._sessionCache.get(rawId); if (!cached) { @@ -1190,11 +1225,12 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (existing) { this._runningSessionConfigs.set(sessionId, { ...existing, - values: { ...existing.values, ...config }, + values: replace ? { ...config } : { ...existing.values, ...config }, }); } else { // Session was restored (e.g. after reload) — create a minimal // config entry from the changed values so the picker can render. + // `replace` vs merge is moot here (no existing values to merge with). this._runningSessionConfigs.set(sessionId, { schema: { type: 'object', properties: buildMutableConfigSchema(config) }, values: config, diff --git a/src/vs/sessions/contrib/agentHost/browser/localAgentHost.contribution.ts b/src/vs/sessions/contrib/agentHost/browser/localAgentHost.contribution.ts index 7fe3b2cb654ca..bdba3fc6b6f60 100644 --- a/src/vs/sessions/contrib/agentHost/browser/localAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/agentHost/browser/localAgentHost.contribution.ts @@ -8,7 +8,9 @@ import { AgentHostEnabledSettingId } from '../../../../platform/agentHost/common import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { AgentHostContribution } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.js'; import { IAgentHostSessionWorkingDirectoryResolver } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; +import { AgentHostTerminalContribution } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { LocalAgentHostSessionsProvider } from './localAgentHostSessionsProvider.js'; @@ -16,11 +18,11 @@ import { LocalAgentHostSessionsProvider } from './localAgentHostSessionsProvider * Registers the {@link LocalAgentHostSessionsProvider} as a sessions provider * when `chat.agentHost.enabled` is true. * - * The existing {@link AgentHostContribution} (from `chat/electron-browser/chat.contribution.js`) - * handles all the heavy lifting — agent discovery, session handler registration, - * language model providers, customization harness — via {@link IChatSessionsService}. - * This contribution only bridges the session listing and lifecycle to the - * {@link ISessionsProvidersService} layer used by the Sessions app's UI. + * {@link AgentHostContribution} handles all the heavy lifting — agent discovery, + * session handler registration, language model providers, customization harness — + * via {@link IChatSessionsService}. This contribution only bridges the session + * listing and lifecycle to the {@link ISessionsProvidersService} layer used by + * the Sessions app's UI. */ class LocalAgentHostContribution extends Disposable implements IWorkbenchContribution { @@ -65,4 +67,6 @@ class LocalAgentHostContribution extends Disposable implements IWorkbenchContrib } } +registerWorkbenchContribution2(AgentHostContribution.ID, AgentHostContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(AgentHostTerminalContribution.ID, AgentHostTerminalContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(LocalAgentHostContribution.ID, LocalAgentHostContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts index c3b3588093f4c..587aa5c248fb0 100644 --- a/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts @@ -5,13 +5,15 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { Schemas } from '../../../../base/common/network.js'; import { autorun, IObservable } from '../../../../base/common/observable.js'; -import { basename } from '../../../../base/common/resources.js'; +import { basename, dirname } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { IAgentConnection, IAgentHostService, type IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; @@ -49,6 +51,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide @IChatService chatService: IChatService, @IChatWidgetService chatWidgetService: IChatWidgetService, @ILanguageModelsService languageModelsService: ILanguageModelsService, + @ILabelService private readonly _labelService: ILabelService, ) { super(chatSessionsService, chatService, chatWidgetService, languageModelsService); @@ -109,8 +112,11 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide protected _adapterOptions() { return { description: this._localDescription, - buildWorkspace: (project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined) => - LocalAgentHostSessionsProvider.buildWorkspace(project, workingDirectory, this._localLabel), + 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._localLabel, fallbackIcon: Codicon.folder, requiresWorkspaceTrust: true, description }); + }, }; } @@ -129,14 +135,15 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide // -- Workspaces ---------------------------------------------------------- - static buildWorkspace(project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined, providerLabel: string): ISessionWorkspace | undefined { - return buildAgentHostSessionWorkspace(project, workingDirectory, { providerLabel, fallbackIcon: Codicon.folder, requiresWorkspaceTrust: true }); - } - - resolveWorkspace(repositoryUri: URI): ISessionWorkspace { + resolveWorkspace(repositoryUri: URI): ISessionWorkspace | undefined { + if (repositoryUri.scheme !== Schemas.file) { + return undefined; + } const folderName = basename(repositoryUri) || repositoryUri.path; return { label: `${folderName} [${this._localLabel}]`, + description: this._labelService.getUriLabel(dirname(repositoryUri), { relative: false }), + group: this.label, icon: Codicon.folder, repositories: [{ uri: repositoryUri, workingDirectory: undefined, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }], requiresWorkspaceTrust: true, diff --git a/src/vs/sessions/contrib/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts b/src/vs/sessions/contrib/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts new file mode 100644 index 0000000000000..4f52eef17bf55 --- /dev/null +++ b/src/vs/sessions/contrib/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts @@ -0,0 +1,314 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import type { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { NullLogService, ILogService } from '../../../../../platform/log/common/log.js'; +import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../../../../platform/jsonschemas/common/jsonContributionRegistry.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import type { IAgentHostSessionsProvider } from '../../../../common/agentHostSessionsProvider.js'; +import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; +import type { ISession } from '../../../../services/sessions/common/session.js'; +import type { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; +import { agentSessionSettingsUri, AgentSessionSettingsFileSystemProvider, AgentSessionSettingsSchemaRegistrar } from '../../browser/agentSessionSettingsFileSystemProvider.js'; + +const PROVIDER_ID = 'local-agent-host'; +const RESOURCE_SCHEME = 'agent-host-copilot'; +const RAW_ID = 'abc-123'; + +suite('AgentSessionSettingsFileSystemProvider', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createSession(): ISession { + const resource = URI.from({ scheme: RESOURCE_SCHEME, path: `/${RAW_ID}` }); + return { + sessionId: `${PROVIDER_ID}:${resource.toString()}`, + resource, + providerId: PROVIDER_ID, + } as unknown as ISession; + } + + interface ITestHarness { + readonly fs: AgentSessionSettingsFileSystemProvider; + readonly session: ISession; + readonly uri: URI; + readonly sessionProvider: IMockAgentHostSessionsProvider; + } + + interface IMockAgentHostSessionsProvider extends IAgentHostSessionsProvider { + config: ResolveSessionConfigResult | undefined; + readonly onDidChangeSessionConfigEmitter: Emitter; + readonly onDidChangeSessionsEmitter: Emitter<{ added: readonly ISession[]; removed: readonly ISession[]; changed: readonly ISession[] }>; + readonly replaceCalls: Array<{ sessionId: string; values: Record }>; + } + + function createHarness( + initialConfig: ResolveSessionConfigResult | undefined, + registerProvider = true, + ): ITestHarness { + const session = createSession(); + + const onDidChangeSessionConfigEmitter = store.add(new Emitter()); + const onDidChangeSessionsEmitter = store.add(new Emitter<{ added: readonly ISession[]; removed: readonly ISession[]; changed: readonly ISession[] }>()); + const replaceCalls: Array<{ sessionId: string; values: Record }> = []; + + const sessionProvider: IMockAgentHostSessionsProvider = { + id: PROVIDER_ID, + config: initialConfig, + onDidChangeSessionConfigEmitter, + onDidChangeSessionsEmitter, + replaceCalls, + onDidChangeSessionConfig: onDidChangeSessionConfigEmitter.event, + onDidChangeSessions: onDidChangeSessionsEmitter.event, + getSessions: () => [session], + getSessionConfig: (_sessionId: string) => sessionProvider.config, + replaceSessionConfig: async (sessionId: string, values: Record) => { + replaceCalls.push({ sessionId, values }); + if (sessionProvider.config) { + sessionProvider.config = { + ...sessionProvider.config, + values: { ...values }, + }; + } + }, + setSessionConfigValue: async () => { /* unused by writeFile */ }, + } as unknown as IMockAgentHostSessionsProvider; + + const onDidChangeProvidersEmitter = store.add(new Emitter<{ added: readonly ISessionsProvider[]; removed: readonly ISessionsProvider[] }>()); + const providersService: ISessionsProvidersService = { + getProvider(providerId: string): T | undefined { + if (registerProvider && providerId === PROVIDER_ID) { + return sessionProvider as unknown as T; + } + return undefined; + }, + getProviders: () => registerProvider ? [sessionProvider as unknown as ISessionsProvider] : [], + onDidChangeProviders: onDidChangeProvidersEmitter.event, + } as unknown as ISessionsProvidersService; + + const instantiationService = store.add(new TestInstantiationService(new ServiceCollection( + [ISessionsProvidersService, providersService], + [ILogService, new NullLogService()], + ))); + + const schemaRegistrar = store.add(instantiationService.createInstance(AgentSessionSettingsSchemaRegistrar)); + const fs = store.add(instantiationService.createInstance(AgentSessionSettingsFileSystemProvider, schemaRegistrar)); + + return { fs, session, uri: agentSessionSettingsUri(session), sessionProvider }; + } + + test('readFile returns mutable, non-readOnly config values as JSON', async () => { + const { fs, uri } = createHarness({ + schema: { + type: 'object', + properties: { + autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default', 'autoApprove'] }, + isolation: { type: 'string', title: 'Isolation', enum: ['worktree'] }, // non-mutable — omitted + branch: { type: 'string', title: 'Branch', sessionMutable: true, readOnly: true, enum: ['main'] }, // readOnly — omitted + }, + }, + values: { autoApprove: 'default', isolation: 'worktree', branch: 'main' }, + }); + + const buf = await fs.readFile(uri); + const text = VSBuffer.wrap(buf).toString(); + const jsonStart = text.indexOf('{'); + const parsed = JSON.parse(text.substring(jsonStart)); + assert.deepStrictEqual(parsed, { autoApprove: 'default' }); + }); + + test('writeFile with unchanged content still forwards raw input (provider guards/short-circuits)', async () => { + const { fs, uri, session, sessionProvider } = createHarness({ + schema: { + type: 'object', + properties: { + autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default', 'autoApprove'] }, + }, + }, + values: { autoApprove: 'default' }, + }); + + const current = await fs.readFile(uri); + await fs.writeFile(uri, current, { create: false, overwrite: true, unlock: false, atomic: false }); + // FS provider forwards the parsed JSON as-is; the guard/short-circuit + // is the provider's responsibility (covered in the provider test). + assert.deepStrictEqual(sessionProvider.replaceCalls, [{ + sessionId: session.sessionId, + values: { autoApprove: 'default' }, + }]); + }); + + test('writeFile forwards the user\'s parsed JSON as the replace payload', async () => { + const { fs, uri, session, sessionProvider } = createHarness({ + schema: { + type: 'object', + properties: { + autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default', 'autoApprove'] }, + mode: { type: 'string', title: 'Mode', sessionMutable: true, enum: ['a', 'b'] }, + isolation: { type: 'string', title: 'Isolation', enum: ['worktree'] }, // non-mutable + branch: { type: 'string', title: 'Branch', sessionMutable: true, readOnly: true, enum: ['main'] }, // readOnly + }, + }, + values: { autoApprove: 'default', mode: 'a', isolation: 'worktree', branch: 'main' }, + }); + + // User edits: only editable keys are exposed and round-tripped through + // the FS provider. Non-editable preservation is the provider's job. + const newContent = VSBuffer.fromString('// trailing comments ok\n{ "autoApprove": "autoApprove", "mode": "b", }\n').buffer; + await fs.writeFile(uri, newContent, { create: false, overwrite: true, unlock: false, atomic: false }); + + assert.deepStrictEqual(sessionProvider.replaceCalls, [{ + sessionId: session.sessionId, + values: { autoApprove: 'autoApprove', mode: 'b' }, + }]); + }); + + test('writeFile forwards a partial edit set, supporting unset via omission', async () => { + const { fs, uri, session, sessionProvider } = createHarness({ + schema: { + type: 'object', + properties: { + autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default', 'autoApprove'] }, + mode: { type: 'string', title: 'Mode', sessionMutable: true, enum: ['a', 'b'] }, + isolation: { type: 'string', title: 'Isolation', enum: ['worktree'] }, + }, + }, + values: { autoApprove: 'autoApprove', mode: 'a', isolation: 'worktree' }, + }); + + const newContent = VSBuffer.fromString('{ "autoApprove": "default" }\n').buffer; + await fs.writeFile(uri, newContent, { create: false, overwrite: true, unlock: false, atomic: false }); + + assert.deepStrictEqual(sessionProvider.replaceCalls, [{ + sessionId: session.sessionId, + values: { autoApprove: 'default' }, + }]); + }); + + test('onDidChangeFile fires when provider config changes', async () => { + const { fs, uri, session, sessionProvider } = createHarness({ + schema: { type: 'object', properties: {} }, + values: {}, + }); + + const events: URI[] = []; + const listeners = new DisposableStore(); + store.add(listeners); + listeners.add(fs.onDidChangeFile(changes => { + for (const c of changes) { + events.push(c.resource); + } + })); + const watch = fs.watch(uri, { recursive: false, excludes: [] }); + listeners.add(watch); + + sessionProvider.onDidChangeSessionConfigEmitter.fire(session.sessionId); + + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].toString(), uri.toString()); + }); + + test('readFile on unknown provider throws FileNotFound', async () => { + const { fs, uri } = createHarness(undefined, /*registerProvider*/ false); + + await assert.rejects(async () => { + await fs.readFile(uri); + }); + }); + + suite('schema registration', () => { + const schemaRegistry = Registry.as(JSONExtensions.JSONContribution); + + function expectedSchemaId(session: ISession): string { + return `vscode://schemas/agent-session-settings/${session.providerId}${session.resource.scheme}${session.resource.path}.jsonc`; + } + + test('readFile lazily registers a schema + association for the session', async () => { + const { fs, uri, session } = createHarness({ + schema: { + type: 'object', + properties: { + autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default', 'autoApprove'] }, + }, + }, + values: { autoApprove: 'default' }, + }); + const schemaId = expectedSchemaId(session); + + // No registration before the file is read. + assert.strictEqual(schemaRegistry.hasSchemaContent(schemaId), false); + assert.strictEqual(schemaRegistry.getSchemaAssociations()[schemaId], undefined); + + await fs.readFile(uri); + + assert.strictEqual(schemaRegistry.hasSchemaContent(schemaId), true); + assert.deepStrictEqual(schemaRegistry.getSchemaAssociations()[schemaId], [uri.toString()]); + }); + + test('schema is refreshed when onDidChangeSessionConfig fires with a new schema identity', async () => { + const { fs, uri, session, sessionProvider } = createHarness({ + schema: { + type: 'object', + properties: { + autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default'] }, + }, + }, + values: { autoApprove: 'default' }, + }); + const schemaId = expectedSchemaId(session); + + // Trigger initial registration. + await fs.readFile(uri); + const initial = schemaRegistry.getSchemaContributions().schemas[schemaId]; + assert.ok(initial); + + // Swap in a new schema (identity change) and notify. + sessionProvider.config = { + schema: { + type: 'object', + properties: { + autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default', 'autoApprove'] }, + mode: { type: 'string', title: 'Mode', sessionMutable: true, enum: ['a', 'b'] }, + }, + }, + values: { autoApprove: 'default', mode: 'a' }, + }; + sessionProvider.onDidChangeSessionConfigEmitter.fire(session.sessionId); + + const refreshed = schemaRegistry.getSchemaContributions().schemas[schemaId]; + assert.notStrictEqual(refreshed, initial); + assert.ok(refreshed.properties?.['mode'], 'refreshed schema should include the newly added property'); + }); + + test('schema is disposed when the session is removed', async () => { + const { fs, uri, session, sessionProvider } = createHarness({ + schema: { + type: 'object', + properties: { + autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default'] }, + }, + }, + values: { autoApprove: 'default' }, + }); + const schemaId = expectedSchemaId(session); + + await fs.readFile(uri); + assert.strictEqual(schemaRegistry.hasSchemaContent(schemaId), true); + + sessionProvider.onDidChangeSessionsEmitter.fire({ added: [], removed: [session], changed: [] }); + + assert.strictEqual(schemaRegistry.hasSchemaContent(schemaId), false); + assert.strictEqual(schemaRegistry.getSchemaAssociations()[schemaId], undefined); + }); + }); +}); 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 ebf61ebafa0ce..279685b9008d0 100644 --- a/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -14,12 +14,12 @@ 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 { ISessionAction, ITerminalAction } from '../../../../../platform/agentHost/common/state/protocol/action-origin.generated.js'; -import type { IResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; +import type { 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 IAgentInfo, type IModelSelection, type IRootState, type ISessionConfigState, type ISessionState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { SessionLifecycle, type AgentInfo, type ModelSelection, type RootState, type SessionConfigState, type SessionState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { SessionStatus as ProtocolSessionStatus, StateComponents } from '../../../../../platform/agentHost/common/state/sessionState.js'; -import { ActionType, type IActionEnvelope, type INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js'; +import { ActionType, type ActionEnvelope, type INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IChatWidget, IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; @@ -29,26 +29,27 @@ import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/co import { ISessionChangeEvent } from '../../../../services/sessions/common/sessionsProvider.js'; import { SessionStatus } from '../../../../services/sessions/common/session.js'; import { LocalAgentHostSessionsProvider } from '../../browser/localAgentHostSessionsProvider.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; // ---- Mock IAgentHostService ------------------------------------------------- class MockAgentHostService extends mock() { declare readonly _serviceBrand: undefined; - private readonly _onDidAction = new Emitter(); + private readonly _onDidAction = new Emitter(); override readonly onDidAction = this._onDidAction.event; private readonly _onDidNotification = new Emitter(); override readonly onDidNotification = this._onDidNotification.event; - private readonly _onDidRootStateChange = new Emitter(); - private _rootStateValue: IRootState | Error | undefined = { agents: [{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as IAgentInfo] }; - override readonly rootState: IAgentSubscription; + private readonly _onDidRootStateChange = new Emitter(); + private _rootStateValue: RootState | Error | undefined = { agents: [{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo] }; + override readonly rootState: IAgentSubscription; override readonly clientId = 'test-local-client'; private readonly _sessions = new Map(); public disposedSessions: URI[] = []; - public dispatchedActions: { action: ISessionAction | ITerminalAction; clientId: string; clientSeq: number }[] = []; + public dispatchedActions: { action: SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = []; public failResolveSessionConfig = false; - public resolveSessionConfigResult: IResolveSessionConfigResult = { schema: { type: 'object', properties: {} }, values: { isolation: 'worktree' } }; + public resolveSessionConfigResult: ResolveSessionConfigResult = { schema: { type: 'object', properties: {} }, values: { isolation: 'worktree' } }; private readonly _authenticationPending: ISettableObservable = observableValue('authenticationPending', false); override readonly authenticationPending: IObservable = this._authenticationPending; @@ -84,7 +85,7 @@ class MockAgentHostService extends mock() { this._sessions.delete(rawId); } - override async resolveSessionConfig(): Promise { + override async resolveSessionConfig(): Promise { await Promise.resolve(); if (this.failResolveSessionConfig) { throw new Error('resolveSessionConfig unavailable'); @@ -92,11 +93,11 @@ class MockAgentHostService extends mock() { return this.resolveSessionConfigResult; } - dispatchAction(action: ISessionAction | ITerminalAction, clientId: string, clientSeq: number): void { + dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void { this.dispatchedActions.push({ action, clientId, clientSeq }); } - override dispatch(action: ISessionAction | ITerminalAction): void { + override dispatch(action: SessionAction | TerminalAction): void { this.dispatchedActions.push({ action, clientId: this.clientId, clientSeq: this._nextSeq++ }); } @@ -107,8 +108,8 @@ class MockAgentHostService extends mock() { // ---- Session-state subscriptions --------------------------------------- - private readonly _sessionStateEmitters = new Map>(); - private readonly _sessionStateValues = new Map(); + private readonly _sessionStateEmitters = new Map>(); + private readonly _sessionStateValues = new Map(); public sessionSubscribeCounts = new Map(); public sessionUnsubscribeCounts = new Map(); @@ -117,7 +118,7 @@ class MockAgentHostService extends mock() { this.sessionSubscribeCounts.set(key, (this.sessionSubscribeCounts.get(key) ?? 0) + 1); let emitter = this._sessionStateEmitters.get(key); if (!emitter) { - emitter = new Emitter(); + emitter = new Emitter(); this._sessionStateEmitters.set(key, emitter); } const self = this; @@ -136,13 +137,13 @@ class MockAgentHostService extends mock() { }; } - setSessionState(rawId: string, provider: string, state: ISessionState): void { + setSessionState(rawId: string, provider: string, state: SessionState): void { const key = AgentSession.uri(provider, rawId).toString(); this._sessionStateValues.set(key, state); this._sessionStateEmitters.get(key)?.fire(state); } - setAgents(agents: IAgentInfo[]): void { + setAgents(agents: AgentInfo[]): void { this._rootStateValue = { agents }; this._onDidRootStateChange.fire(this._rootStateValue); } @@ -159,7 +160,7 @@ class MockAgentHostService extends mock() { this._onDidNotification.fire(n); } - fireAction(envelope: IActionEnvelope): void { + fireAction(envelope: ActionEnvelope): void { this._onDidAction.fire(envelope); } @@ -210,11 +211,14 @@ function createProvider(disposables: DisposableStore, agentHostService: MockAgen instantiationService.stub(ILanguageModelsService, { lookupLanguageModel: () => undefined, }); + instantiationService.stub(ILabelService, { + getUriLabel: (uri: URI) => uri.path, + }); return disposables.add(instantiationService.createInstance(LocalAgentHostSessionsProvider)); } -async function waitForSessionConfig(provider: LocalAgentHostSessionsProvider, sessionId: string, predicate: (config: IResolveSessionConfigResult | undefined) => boolean): Promise { +async function waitForSessionConfig(provider: LocalAgentHostSessionsProvider, sessionId: string, predicate: (config: ResolveSessionConfigResult | undefined) => boolean): Promise { if (predicate(provider.getSessionConfig(sessionId))) { return; } @@ -296,8 +300,8 @@ suite('LocalAgentHostSessionsProvider', () => { disposables.add(provider.onDidChangeSessionTypes!(() => changes++)); agentHost.setAgents([ - { provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as IAgentInfo, - { provider: 'openai', displayName: 'OpenAI', description: '', models: [] } as IAgentInfo, + { provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo, + { provider: 'openai', displayName: 'OpenAI', description: '', models: [] } as AgentInfo, ]); assert.strictEqual(changes, 1); @@ -339,6 +343,7 @@ suite('LocalAgentHostSessionsProvider', () => { const uri = URI.parse('file:///home/user/project'); const ws = provider.resolveWorkspace(uri); + assert.ok(ws, 'resolveWorkspace should resolve file:// URIs'); assert.strictEqual(ws.label, 'project [Local]'); assert.strictEqual(ws.repositories.length, 1); assert.strictEqual(ws.repositories[0].uri.toString(), uri.toString()); @@ -693,7 +698,7 @@ suite('LocalAgentHostSessionsProvider', () => { }, serverSeq: 1, origin: undefined, - } as IActionEnvelope); + } as ActionEnvelope); assert.strictEqual(target!.title.get(), 'Server Title'); assert.strictEqual(changes.length, 1); @@ -714,11 +719,11 @@ suite('LocalAgentHostSessionsProvider', () => { action: { type: ActionType.SessionModelChanged, session: AgentSession.uri('copilotcli', 'model-change').toString(), - model: { id: 'new-model' } satisfies IModelSelection, + model: { id: 'new-model' } satisfies ModelSelection, }, serverSeq: 1, origin: undefined, - } as IActionEnvelope); + } as ActionEnvelope); assert.strictEqual(target!.modelId.get(), 'agent-host-copilotcli:new-model'); assert.strictEqual(changes.length, 1); @@ -747,7 +752,7 @@ suite('LocalAgentHostSessionsProvider', () => { }, serverSeq: 1, origin: undefined, - } as IActionEnvelope); + } as ActionEnvelope); await timeout(0); @@ -874,9 +879,9 @@ suite('LocalAgentHostSessionsProvider', () => { assert.deepStrictEqual(sendOptions.map(options => options.agentHostSessionConfig), [{ isolation: 'worktree' }]); }); - // ---- Running session config seeding (from ISessionState.config) ------- + // ---- Running session config seeding (from SessionState.config) ------- - test('getSessionConfig seeds running config from session state subscription, filtered to sessionMutable properties', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + test('getSessionConfig seeds running config from session state subscription with full schema', () => runWithFakedTimers({ useFakeTimers: true }, async () => { agentHost.addSession(createSession('seed-1', { summary: 'Seeded Session' })); const provider = createProvider(disposables, agentHost); provider.getSessions(); @@ -890,7 +895,7 @@ suite('LocalAgentHostSessionsProvider', () => { // Now have the fake host hydrate the session-state snapshot with a // config containing one mutable and one read-only property. - const config: ISessionConfigState = { + const config: SessionConfigState = { schema: { type: 'object', properties: { @@ -900,7 +905,7 @@ suite('LocalAgentHostSessionsProvider', () => { }, values: { autoApprove: 'default', isolation: 'worktree' }, }; - const fakeState: ISessionState = { + const fakeState: SessionState = { summary: { resource: AgentSession.uri('copilotcli', 'seed-1').toString(), provider: 'copilotcli', title: 'Seeded Session', status: ProtocolSessionStatus.Idle, createdAt: 0, modifiedAt: 0 }, lifecycle: SessionLifecycle.Ready, turns: [], @@ -910,13 +915,16 @@ suite('LocalAgentHostSessionsProvider', () => { await waitForSessionConfig(provider, session!.sessionId, c => c?.values.autoApprove === 'default'); + // The full schema + values are retained (non-mutable values are + // required by the JSONC settings editor to round-trip via replace + // semantics without dropping server-side config). const seeded = provider.getSessionConfig(session!.sessionId); assert.deepStrictEqual({ - properties: Object.keys(seeded?.schema.properties ?? {}), + properties: Object.keys(seeded?.schema.properties ?? {}).sort(), values: seeded?.values, }, { - properties: ['autoApprove'], - values: { autoApprove: 'default' }, + properties: ['autoApprove', 'isolation'], + values: { autoApprove: 'default', isolation: 'worktree' }, }); })); @@ -938,4 +946,178 @@ suite('LocalAgentHostSessionsProvider', () => { assert.strictEqual(agentHost.sessionUnsubscribeCounts.get(sessionUriStr), 1); })); + + // ---- replaceSessionConfig ------- + + test('replaceSessionConfig only replaces sessionMutable, non-readOnly values and preserves everything else', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.addSession(createSession('rep-1', { summary: 'Replace Session' })); + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + const session = provider.getSessions().find(s => s.title.get() === 'Replace Session'); + assert.ok(session); + + const config: SessionConfigState = { + schema: { + type: 'object', + properties: { + autoApprove: { type: 'string', title: 'Auto Approve', enum: ['default', 'autoApprove'], sessionMutable: true }, + isolation: { type: 'string', title: 'Isolation', enum: ['folder', 'worktree'] }, // non-mutable + branch: { type: 'string', title: 'Branch', enum: ['main'], sessionMutable: true, readOnly: true }, // readOnly + }, + }, + values: { autoApprove: 'default', isolation: 'worktree', branch: 'main' }, + }; + const fakeState: SessionState = { + summary: { resource: AgentSession.uri('copilotcli', 'rep-1').toString(), provider: 'copilotcli', title: 'Replace Session', status: ProtocolSessionStatus.Idle, createdAt: 0, modifiedAt: 0 }, + lifecycle: SessionLifecycle.Ready, + turns: [], + config, + }; + agentHost.setSessionState('rep-1', 'copilotcli', fakeState); + await waitForSessionConfig(provider, session!.sessionId, c => c?.values.autoApprove === 'default'); + + // Caller attempts to change everything — including non-mutable + // `isolation`, readOnly `branch`, and an unknown `rogue` key. Only + // `autoApprove` should actually change; all other values must be + // carried through unchanged and `rogue` must be dropped. + await provider.replaceSessionConfig(session!.sessionId, { + autoApprove: 'autoApprove', + isolation: 'folder', + branch: 'other', + rogue: 'ignored', + }); + + const sessionUri = AgentSession.uri('copilotcli', 'rep-1').toString(); + const configChanged = agentHost.dispatchedActions.find(d => d.action.type === ActionType.SessionConfigChanged && (d.action as { session: string }).session === sessionUri); + assert.ok(configChanged, 'a SessionConfigChanged action should be dispatched'); + assert.deepStrictEqual(configChanged.action, { + type: ActionType.SessionConfigChanged, + session: sessionUri, + config: { autoApprove: 'autoApprove', isolation: 'worktree', branch: 'main' }, + replace: true, + }); + + const latest = provider.getSessionConfig(session!.sessionId); + assert.deepStrictEqual(latest?.values, { autoApprove: 'autoApprove', isolation: 'worktree', branch: 'main' }); + })); + + test('replaceSessionConfig is a no-op when nothing editable actually changes', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.addSession(createSession('rep-2', { summary: 'No-op Session' })); + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + const session = provider.getSessions().find(s => s.title.get() === 'No-op Session'); + assert.ok(session); + + const config: SessionConfigState = { + schema: { + type: 'object', + properties: { + autoApprove: { type: 'string', title: 'Auto Approve', enum: ['default', 'autoApprove'], sessionMutable: true }, + isolation: { type: 'string', title: 'Isolation', enum: ['folder', 'worktree'] }, + }, + }, + values: { autoApprove: 'default', isolation: 'worktree' }, + }; + const fakeState: SessionState = { + summary: { resource: AgentSession.uri('copilotcli', 'rep-2').toString(), provider: 'copilotcli', title: 'No-op Session', status: ProtocolSessionStatus.Idle, createdAt: 0, modifiedAt: 0 }, + lifecycle: SessionLifecycle.Ready, + turns: [], + config, + }; + agentHost.setSessionState('rep-2', 'copilotcli', fakeState); + await waitForSessionConfig(provider, session!.sessionId, c => c?.values.autoApprove === 'default'); + + const before = agentHost.dispatchedActions.length; + // Caller re-asserts the same editable value; everything else either + // matches or is non-editable. + await provider.replaceSessionConfig(session!.sessionId, { autoApprove: 'default' }); + assert.strictEqual(agentHost.dispatchedActions.length, before, 'no action should be dispatched'); + })); + + // ---- Server-echoed SessionConfigChanged ------- + + test('server-echoed SessionConfigChanged merges config values into the running cache by default', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.addSession(createSession('cfg-merge', { summary: 'Merge Session' })); + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + const session = provider.getSessions().find(s => s.title.get() === 'Merge Session'); + assert.ok(session); + + const fakeState: SessionState = { + summary: { resource: AgentSession.uri('copilotcli', 'cfg-merge').toString(), provider: 'copilotcli', title: 'Merge Session', status: ProtocolSessionStatus.Idle, createdAt: 0, modifiedAt: 0 }, + lifecycle: SessionLifecycle.Ready, + turns: [], + config: { + schema: { + type: 'object', + properties: { + autoApprove: { type: 'string', title: 'Auto Approve', enum: ['default', 'autoApprove'], sessionMutable: true }, + isolation: { type: 'string', title: 'Isolation', enum: ['folder', 'worktree'] }, + }, + }, + values: { autoApprove: 'default', isolation: 'worktree' }, + }, + }; + agentHost.setSessionState('cfg-merge', 'copilotcli', fakeState); + await waitForSessionConfig(provider, session!.sessionId, c => c?.values.autoApprove === 'default'); + + agentHost.fireAction({ + action: { + type: ActionType.SessionConfigChanged, + session: AgentSession.uri('copilotcli', 'cfg-merge').toString(), + config: { autoApprove: 'autoApprove' }, + }, + serverSeq: 1, + origin: undefined, + } as ActionEnvelope); + + const updated = provider.getSessionConfig(session!.sessionId); + assert.deepStrictEqual(updated?.values, { autoApprove: 'autoApprove', isolation: 'worktree' }); + })); + + test('server-echoed SessionConfigChanged with replace:true overwrites the running cache', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.addSession(createSession('cfg-replace', { summary: 'Replace Session' })); + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + const session = provider.getSessions().find(s => s.title.get() === 'Replace Session'); + assert.ok(session); + + const fakeState: SessionState = { + summary: { resource: AgentSession.uri('copilotcli', 'cfg-replace').toString(), provider: 'copilotcli', title: 'Replace Session', status: ProtocolSessionStatus.Idle, createdAt: 0, modifiedAt: 0 }, + lifecycle: SessionLifecycle.Ready, + turns: [], + config: { + schema: { + type: 'object', + properties: { + autoApprove: { type: 'string', title: 'Auto Approve', enum: ['default', 'autoApprove'], sessionMutable: true }, + mode: { type: 'string', title: 'Mode', enum: ['a', 'b'], sessionMutable: true }, + isolation: { type: 'string', title: 'Isolation', enum: ['folder', 'worktree'] }, + }, + }, + values: { autoApprove: 'default', mode: 'a', isolation: 'worktree' }, + }, + }; + agentHost.setSessionState('cfg-replace', 'copilotcli', fakeState); + await waitForSessionConfig(provider, session!.sessionId, c => c?.values.autoApprove === 'default'); + + agentHost.fireAction({ + action: { + type: ActionType.SessionConfigChanged, + session: AgentSession.uri('copilotcli', 'cfg-replace').toString(), + config: { autoApprove: 'autoApprove', isolation: 'worktree' }, + replace: true, + }, + serverSeq: 1, + origin: undefined, + } as ActionEnvelope); + + // `mode` is dropped because it wasn't re-asserted in the replace payload. + const updated = provider.getSessionConfig(session!.sessionId); + assert.deepStrictEqual(updated?.values, { autoApprove: 'autoApprove', isolation: 'worktree' }); + })); }); diff --git a/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts b/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts index 4a8c8d9a000ee..a57be3a52ecd9 100644 --- a/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts +++ b/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts @@ -5,21 +5,13 @@ import './media/changesTitleBarWidget.css'; -import { $, append } from '../../../../base/browser/dom.js'; import { mainWindow } from '../../../../base/browser/window.js'; -import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; -import { createInstantHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; -import { IAction } from '../../../../base/common/actions.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; -import { autorun } from '../../../../base/common/observable.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { localize } from '../../../../nls.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; -import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; import { IsAuxiliaryWindowContext, AuxiliaryBarVisibleContext } from '../../../../workbench/common/contextkeys.js'; import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; @@ -27,164 +19,46 @@ import { IPaneCompositePartService } from '../../../../workbench/services/paneco import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { ViewContainerLocation } from '../../../../workbench/common/views.js'; +import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { Menus } from '../../../browser/menus.js'; import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { logChangesViewToggle } from '../../../common/sessionsTelemetry.js'; -import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { CHANGES_VIEW_CONTAINER_ID } from '../common/changes.js'; -import { ISessionFileChange } from '../../../services/sessions/common/session.js'; const TOGGLE_CHANGES_VIEW_ID = 'workbench.action.agentSessions.toggleChangesView'; +const TOGGLE_SECONDARY_SIDEBAR_TOOLTIP = localize('toggleSecondarySidebarTooltip', "Toggle Secondary Side Bar Visibility"); -/** - * Action view item that renders the diff stats indicator (file change counts) - * in the titlebar session toolbar. Shows [diff icon] +insertions -deletions. - * Clicking toggles the auxiliary bar with the Changes view. - */ -class ChangesTitleBarActionViewItem extends BaseActionViewItem { - - private _container: HTMLElement | undefined; - private readonly _indicatorDisposables = this._register(new DisposableStore()); - private readonly _hoverDelegate = this._register(createInstantHoverDelegate()); - - constructor( - action: IAction, - options: IBaseActionViewItemOptions | undefined, - @IHoverService private readonly hoverService: IHoverService, - @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, - @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, - ) { - super(undefined, action, options); - - // Re-render when the active session changes - this._register(autorun(reader => { - this.activeSessionService.activeSession.read(reader); - this._rebuildIndicators(); - })); - - // Re-render when sessions data changes - this._register(this.activeSessionService.onDidChangeSessions(() => { - this._rebuildIndicators(); - })); - - // Update active state when auxiliary bar visibility changes - this._register(this.layoutService.onDidChangePartVisibility(e => { - if (e.partId === Parts.AUXILIARYBAR_PART) { - this._updateActiveState(); - } - })); - } - - override render(container: HTMLElement): void { - super.render(container); - - this._container = container; - container.classList.add('changes-titlebar-indicator'); - container.setAttribute('role', 'button'); - - this._rebuildIndicators(); - this._updateActiveState(); - } - - private _updateActiveState(): void { - const isVisible = this.layoutService.isVisible(Parts.AUXILIARYBAR_PART); - this._container?.classList.toggle('toggled', isVisible); - this._container?.setAttribute('aria-pressed', String(isVisible)); - } - - private _rebuildIndicators(): void { - if (!this._container) { - return; - } - - this._indicatorDisposables.clear(); - - const btn = this._container; - btn.textContent = ''; - - // Get change summary from the active session - const activeSession = this.activeSessionService.activeSession.get(); - const resource = activeSession?.resource; - const session = resource ? this.activeSessionService.getSession(resource) : undefined; - const summary = session ? this._getSessionChangesSummary(session.changes.get()) : undefined; - - // Rebuild inner content: [diff icon] +insertions -deletions - append(btn, $(ThemeIcon.asCSSSelector(Codicon.diffMultiple))); - - if (summary && summary.insertions > 0) { - const insLabel = append(btn, $('span.changes-titlebar-count.changes-titlebar-insertions')); - insLabel.textContent = `+${summary.insertions}`; - } - - if (summary && summary.deletions > 0) { - const delLabel = append(btn, $('span.changes-titlebar-count.changes-titlebar-deletions')); - delLabel.textContent = `-${summary.deletions}`; - } - - if (summary) { - const label = localize('changesSummary', "{0} file(s) changed, {1} insertion(s), {2} deletion(s)", summary.files, summary.insertions, summary.deletions); - btn.setAttribute('aria-label', label); - this._indicatorDisposables.add(this.hoverService.setupManagedHover( - this._hoverDelegate, btn, label - )); - } else { - btn.setAttribute('aria-label', localize('showChanges', "Show Changes")); - this._indicatorDisposables.add(this.hoverService.setupManagedHover( - this._hoverDelegate, btn, - localize('showChanges', "Show Changes") - )); - } - } - - private _getSessionChangesSummary(changes: readonly ISessionFileChange[]): { - files: number; insertions: number; deletions: number; - } | undefined { - if (changes.length === 0) { - return undefined; - } - - let insertions = 0, deletions = 0; - for (const change of changes) { - insertions += change.insertions; - deletions += change.deletions; - } - - return { files: changes.length, insertions, deletions }; - } -} +const secondarySidebarToggleClosedIcon = registerIcon('agent-secondary-sidebar-toggle-closed', Codicon.layoutSidebarRightOff, localize('agentSecondarySidebarToggleClosedIcon', "Icon for the sessions secondary sidebar when closed.")); +const secondarySidebarToggleOpenIcon = registerIcon('agent-secondary-sidebar-toggle-open', Codicon.layoutSidebarRight, localize('agentSecondarySidebarToggleOpenIcon', "Icon for the sessions secondary sidebar when open.")); /** - * Registers the changes indicator action in the titlebar session toolbar - * (`TitleBarSessionMenu`) and provides a custom action view item to render - * the diff stats widget. + * Registers the Changes view toggle action in the titlebar session toolbar. */ export class ChangesTitleBarContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.changesTitleBar'; - constructor( - @IActionViewItemService actionViewItemService: IActionViewItemService, - @IInstantiationService instantiationService: IInstantiationService, - ) { + constructor() { super(); // Register the toggle action in the session toolbar this._register(MenuRegistry.appendMenuItem(Menus.TitleBarSessionMenu, { command: { id: TOGGLE_CHANGES_VIEW_ID, - title: localize('toggleChanges', "Toggle Changes"), - icon: Codicon.diffMultiple, - toggled: AuxiliaryBarVisibleContext, + title: localize2('showChanges', "Show Changes"), + tooltip: TOGGLE_SECONDARY_SIDEBAR_TOOLTIP, + icon: secondarySidebarToggleClosedIcon, + toggled: { + condition: AuxiliaryBarVisibleContext, + icon: secondarySidebarToggleOpenIcon, + title: localize('hideChanges', "Hide Changes"), + tooltip: TOGGLE_SECONDARY_SIDEBAR_TOOLTIP, + }, }, group: 'navigation', order: 11, // After Run Script (8), Open in VS Code (9), and Open Terminal (10) when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), })); - - // Provide a custom action view item that renders the diff stats - this._register(actionViewItemService.register(Menus.TitleBarSessionMenu, TOGGLE_CHANGES_VIEW_ID, (action, options) => { - return instantiationService.createInstance(ChangesTitleBarActionViewItem, action, options); - })); } } @@ -193,8 +67,15 @@ registerAction2(class extends Action2 { constructor() { super({ id: TOGGLE_CHANGES_VIEW_ID, - title: localize('toggleChanges', "Toggle Changes"), - icon: Codicon.diffMultiple, + title: localize2('showChanges', "Show Changes"), + tooltip: TOGGLE_SECONDARY_SIDEBAR_TOOLTIP, + icon: secondarySidebarToggleClosedIcon, + toggled: { + condition: AuxiliaryBarVisibleContext, + icon: secondarySidebarToggleOpenIcon, + title: localize('hideChanges', "Hide Changes"), + tooltip: TOGGLE_SECONDARY_SIDEBAR_TOOLTIP, + }, precondition: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), }); } diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 4c5f2f1bfb57c..fea320e575137 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -1250,7 +1250,7 @@ class ChangesPickerActionItem extends ActionWidgetDropdownActionViewItem { ...action, id: 'chatEditing.versionsBranchChanges', label: localize('chatEditing.versionsBranchChanges', 'Branch Changes'), - description: branchName && baseBranchName + detail: branchName && baseBranchName ? `${branchName} → ${baseBranchName}` : branchName, checked: viewModel.versionModeObs.get() === ChangesVersionMode.BranchChanges, @@ -1270,7 +1270,7 @@ class ChangesPickerActionItem extends ActionWidgetDropdownActionViewItem { ...action, id: 'chatEditing.versionsUncommittedChanges', label: localize('chatEditing.versionsUncommittedChanges', 'Uncommitted Changes'), - description: localize('chatEditing.versionsUncommittedChanges.description', 'Show uncommitted changes in this session'), + detail: localize('chatEditing.versionsUncommittedChanges.description', 'Show uncommitted changes in this session'), checked: viewModel.versionModeObs.get() === ChangesVersionMode.UncommittedChanges, category: { label: 'changes', order: 2, showHeader: false }, enabled: viewModel.activeSessionTypeObs.get() !== COPILOT_CLOUD_SESSION_TYPE, @@ -1286,7 +1286,7 @@ class ChangesPickerActionItem extends ActionWidgetDropdownActionViewItem { ...action, id: 'chatEditing.versionsAllChanges', label: localize('chatEditing.versionsAllChanges', 'All Changes'), - description: localize('chatEditing.versionsAllChanges.description', 'Show all changes made in this session'), + detail: localize('chatEditing.versionsAllChanges.description', 'Show all changes made in this session'), checked: viewModel.versionModeObs.get() === ChangesVersionMode.AllChanges, category: { label: 'checkpoints', order: 3, showHeader: false }, enabled: viewModel.activeSessionTypeObs.get() === COPILOT_CLOUD_SESSION_TYPE || @@ -1304,7 +1304,7 @@ class ChangesPickerActionItem extends ActionWidgetDropdownActionViewItem { ...action, id: 'chatEditing.versionsLastTurnChanges', label: localize('chatEditing.versionsLastTurnChanges', "Last Turn's Changes"), - description: localize('chatEditing.versionsLastTurnChanges.description', 'Show only changes from the last turn'), + detail: localize('chatEditing.versionsLastTurnChanges.description', 'Show only changes from the last turn'), checked: viewModel.versionModeObs.get() === ChangesVersionMode.LastTurn, category: { label: 'checkpoints', order: 4, showHeader: false }, enabled: viewModel.activeSessionTypeObs.get() === COPILOT_CLOUD_SESSION_TYPE || @@ -1324,7 +1324,7 @@ class ChangesPickerActionItem extends ActionWidgetDropdownActionViewItem { }, }; - super(action, { actionProvider, listOptions: { descriptionBelow: true } }, actionWidgetService, keybindingService, contextKeyService, telemetryService); + super(action, { actionProvider, listOptions: {} }, actionWidgetService, keybindingService, contextKeyService, telemetryService); this._register(autorun(reader => { viewModel.versionModeObs.read(reader); diff --git a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts index e9c4da9760068..53df074fa2fb4 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts @@ -84,6 +84,7 @@ export class ViewAllSessionChangesAction extends Action2 { id: MenuId.ChatEditingSessionChangesToolbar, group: 'navigation', order: 10, + when: ContextKeyExpr.false() } ], }); diff --git a/src/vs/sessions/contrib/changes/browser/media/changesTitleBarWidget.css b/src/vs/sessions/contrib/changes/browser/media/changesTitleBarWidget.css index e6c74dba2d0e8..c77b454718e1a 100644 --- a/src/vs/sessions/contrib/changes/browser/media/changesTitleBarWidget.css +++ b/src/vs/sessions/contrib/changes/browser/media/changesTitleBarWidget.css @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* ---- Changes Titlebar Indicator (in right toolbar) ---- */ +/* ---- Changes titlebar action spacing ---- */ /* Separator between local-session actions (Run, VS Code) and fixed toggles (Terminal, Changes). * Targets the action following the VS Code icon (any Codicon.vscode variant). */ @@ -21,46 +21,3 @@ height: 16px; background-color: var(--vscode-disabledForeground); } - -.agent-sessions-workbench .changes-titlebar-indicator { - display: flex; - align-items: center; - justify-content: center; - height: 22px; - padding: 0 4px; - border-radius: var(--vscode-cornerRadius-medium); - cursor: pointer; - color: inherit; - gap: 3px; -} - -.agent-sessions-workbench .changes-titlebar-indicator:hover { - background: var(--vscode-toolbar-hoverBackground); -} - -.agent-sessions-workbench .changes-titlebar-indicator.toggled, -.agent-sessions-workbench .changes-titlebar-indicator.toggled:hover { - background: var(--vscode-toolbar-activeBackground); -} - -.agent-sessions-workbench .changes-titlebar-indicator .codicon { - font-size: 16px; - color: inherit; -} - -.agent-sessions-workbench .changes-titlebar-count { - font-size: 11px; - font-variant-numeric: tabular-nums; - line-height: 16px; - color: inherit; -} - -.agent-sessions-workbench .changes-titlebar-insertions { - color: var(--vscode-gitDecoration-addedResourceForeground); - font-weight: 600; -} - -.agent-sessions-workbench .changes-titlebar-deletions { - color: var(--vscode-gitDecoration-deletedResourceForeground); - font-weight: 600; -} diff --git a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts index 75d660a448ecc..393924b897602 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts @@ -5,7 +5,7 @@ import { Disposable, DisposableMap } from '../../../../../base/common/lifecycle.js'; import { derived, IObservable, IReader, observableSignal } from '../../../../../base/common/observable.js'; -import { ISessionConfigPropertySchema } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; +import { SessionConfigPropertySchema } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import { IPermissionPickerDelegate } from '../../../../contrib/copilotChatSessions/browser/permissionPicker.js'; import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../../common/agentHostSessionsProvider.js'; @@ -45,7 +45,7 @@ const REQUIRED_AUTO_APPROVE_VALUE = 'default'; * gating, and policy enforcement) or fall back to the generic per-property * picker. */ -export function isWellKnownAutoApproveSchema(schema: ISessionConfigPropertySchema): boolean { +export function isWellKnownAutoApproveSchema(schema: SessionConfigPropertySchema): boolean { if (schema.type !== 'string' || !Array.isArray(schema.enum) || schema.enum.length === 0) { return false; } diff --git a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts index d1de554901d65..0d5ef522787c1 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts @@ -24,7 +24,7 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { AgentHostSessionConfigBranchNameHintKey } from '../../../../../platform/agentHost/common/agentService.js'; -import type { ISessionConfigPropertySchema, ISessionConfigValueItem } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; +import type { SessionConfigPropertySchema, SessionConfigValueItem } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ChatConfiguration } from '../../../../../workbench/contrib/chat/common/constants.js'; import { ChatContextKeyExprs } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; @@ -66,7 +66,7 @@ interface IConfigPickerItem { readonly description?: string; } -function getConfigIcon(property: string, value: string | undefined): ThemeIcon | undefined { +function getConfigIcon(property: string, value: unknown | undefined): ThemeIcon | undefined { if (property === 'isolation') { if (value === 'folder') { return Codicon.folder; @@ -90,7 +90,7 @@ function getConfigIcon(property: string, value: string | undefined): ThemeIcon | return undefined; } -function toActionItems(property: string, items: readonly IConfigPickerItem[], currentValue: string | undefined, policyRestricted?: boolean): IActionListItem[] { +function toActionItems(property: string, items: readonly IConfigPickerItem[], currentValue: unknown | undefined, policyRestricted?: boolean): IActionListItem[] { return items.map(item => ({ kind: ActionListItemKind.Action, label: item.label, @@ -212,7 +212,7 @@ async function confirmAutoApproveLevel(value: string, dialogService: IDialogServ /** * Applies warning/info CSS classes to a trigger element for auto-approve levels. */ -function applyAutoApproveTriggerStyles(trigger: HTMLElement, property: string | undefined, value: string | undefined): void { +function applyAutoApproveTriggerStyles(trigger: HTMLElement, property: string | undefined, value: unknown | undefined): void { if (property === AUTO_APPROVE_PROPERTY) { trigger.classList.toggle('warning', value === 'autopilot'); trigger.classList.toggle('info', value === 'autoApprove'); @@ -282,10 +282,30 @@ class AgentHostSessionConfigPicker extends Disposable { return; } + // In the running-session flow only `sessionMutable` properties can + // actually be changed (non-mutable ones would no-op in + // `setSessionConfigValue`). In the new-session flow any property is + // changeable because changes trigger a full config re-resolve — so + // non-mutable properties like `isolation` must remain visible and + // interactive there. + const isNewSession = provider.getCreateSessionConfig(session.sessionId) !== undefined; + for (const [property, schema] of Object.entries(resolvedConfig.schema.properties)) { if (property === AgentHostSessionConfigBranchNameHintKey) { continue; } + // Only render pickers for properties we know how to present. Today + // that's string properties with an `enum` — anything else (objects, + // arrays, free-form strings, numbers, booleans) has no enumerable + // choice set and is edited through the JSONC settings editor instead. + if (schema.type !== 'string' || !schema.enum || schema.enum.length === 0) { + continue; + } + // In a running session, skip non-mutable properties — they can't + // be changed and would render as dead pills. + if (!isNewSession && !schema.sessionMutable) { + continue; + } // When the autoApprove property uses the well-known schema, the // workbench `PermissionPickerActionItem` (registered separately for // `Menus.NewSessionControl`) handles it — skip it here to avoid @@ -301,7 +321,7 @@ class AgentHostSessionConfigPicker extends Disposable { } } - private _renderTrigger(trigger: HTMLElement, property: string, schema: ISessionConfigPropertySchema, value: string | undefined): void { + private _renderTrigger(trigger: HTMLElement, property: string, schema: SessionConfigPropertySchema, value: unknown | undefined): void { dom.clearNode(trigger); const icon = getConfigIcon(property, value); if (icon) { @@ -319,7 +339,7 @@ class AgentHostSessionConfigPicker extends Disposable { applyAutoApproveTriggerStyles(trigger, property, value); } - private async _showPicker(provider: IAgentHostSessionsProvider, sessionId: string, property: string, schema: ISessionConfigPropertySchema, trigger: HTMLElement): Promise { + private async _showPicker(provider: IAgentHostSessionsProvider, sessionId: string, property: string, schema: SessionConfigPropertySchema, trigger: HTMLElement): Promise { if (schema.readOnly || this._actionWidgetService.isVisible) { return; } @@ -368,7 +388,7 @@ class AgentHostSessionConfigPicker extends Disposable { ); } - private async _getItems(provider: IAgentHostSessionsProvider, sessionId: string, property: string, schema: ISessionConfigPropertySchema, query?: string): Promise { + private async _getItems(provider: IAgentHostSessionsProvider, sessionId: string, property: string, schema: SessionConfigPropertySchema, query?: string): Promise { const dynamicItems = schema.enumDynamic ? await provider.getSessionConfigCompletions(sessionId, property, query) : undefined; @@ -383,7 +403,7 @@ class AgentHostSessionConfigPicker extends Disposable { })); } - private _fromCompletionItem(item: ISessionConfigValueItem): IConfigPickerItem { + private _fromCompletionItem(item: SessionConfigValueItem): IConfigPickerItem { return { value: item.value, label: item.label, @@ -391,7 +411,7 @@ class AgentHostSessionConfigPicker extends Disposable { }; } - private _getLabel(schema: ISessionConfigPropertySchema, value: string | undefined): string { + private _getLabel(schema: SessionConfigPropertySchema, value: unknown | undefined): string { if (typeof value === 'string') { const index = schema.enum?.indexOf(value) ?? -1; return index >= 0 ? schema.enumLabels?.[index] ?? value : value; diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css index dfdd722418882..6cbcecec56e4a 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -186,9 +186,9 @@ } /* Delightful gradient styling for the chat send (submit) button. The button - gets a multi-color gradient ring at rest using the same palette as the - working-state border, fills with a slowly cycling color on hover/focus, - and emits a quick colorful pulse on click. Gated behind the experimental + is filled at rest with a slowly rotating multi-color conic gradient using + the same palette as the working-state border, and emits a quick colorful + pulse on click. Gated behind the experimental `sessions.experimental.sendButtonGradient` setting via the `.sessions-experimental-send-button-gradient` class on the workbench root. */ @property --chat-send-button-anim-angle { @@ -309,9 +309,10 @@ setting via the `.sessions-experimental-send-button-gradient` class on the workbench root. - Colors are darkened to match the hover treatment (60% mixed with input - background), and the conic stops are asymmetric so the fill has a clear - head and tail rather than mirroring around the mid-point. */ + Colors are darkened (60% mixed with input background) so the gradient + reads as a calm fill rather than a saturated accent, and the conic stops + are asymmetric so the fill has a clear head and tail rather than + mirroring around the mid-point. */ .monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button .monaco-button:not(.disabled) { background: conic-gradient(from var(--chat-send-button-anim-angle) at 0% 0%, color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 60%, var(--vscode-input-background)) 0deg, @@ -319,17 +320,16 @@ color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor3) 60%, var(--vscode-input-background)) 200deg, color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 60%, var(--vscode-input-background)) 360deg); color: var(--vscode-button-foreground); - animation: chat-send-button-spin 18s linear infinite; - transition: box-shadow 250ms ease; + animation: chat-send-button-spin 8s linear infinite; + transition: box-shadow 120ms ease; } -/* Hover/focus: soft multi-color glow around the button (no size change). */ +/* Hover/focus: subtle dark overlay to match standard toolbar button hover + feedback. Uses an inset box-shadow so the rotating gradient background is + preserved underneath. */ .monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button:has(.monaco-button:not(.disabled):hover) .monaco-button, .monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button:has(.monaco-button:not(.disabled):focus-visible) .monaco-button { - box-shadow: - 0 0 4px 0 color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor2) 70%, transparent), - 0 0 8px 1px color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor3) 55%, transparent); - animation-duration: 6s; + box-shadow: inset 0 0 0 100px rgba(0, 0, 0, 0.12); } /* Click: outward color pulse on the wrapper. */ @@ -379,7 +379,7 @@ } /* Idle: fill the entire action-label with a slowly rotating conic gradient. - Colors darkened to match hover (60% mixed with input background), with + Colors darkened (60% mixed with input background) for a calm fill, with asymmetric conic stops. */ .monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up { background: conic-gradient(from var(--chat-send-button-anim-angle) at 0% 0%, @@ -389,21 +389,20 @@ color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 60%, var(--vscode-input-background)) 360deg) !important; color: var(--vscode-button-foreground) !important; border-radius: 5px; - animation: chat-send-button-spin 18s linear infinite; - transition: box-shadow 250ms ease; + animation: chat-send-button-spin 8s linear infinite; + transition: box-shadow 120ms ease; /* Lift the label above the click pulse `::after` on the action-item parent so the pulse appears to expand from behind the button, not on top of it. */ position: relative; z-index: 1; } -/* Hover/focus: soft multi-color glow around the button (no size change). */ +/* Hover/focus: subtle dark overlay to match standard toolbar button hover + feedback. Uses an inset box-shadow so the rotating gradient background is + preserved underneath. */ .monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up:hover, .monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up:focus-visible { - box-shadow: - 0 0 4px 0 color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor2) 70%, transparent), - 0 0 8px 1px color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor3) 55%, transparent); - animation-duration: 6s; + box-shadow: inset 0 0 0 100px rgba(0, 0, 0, 0.12); } .monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled):has(> .action-label.codicon-arrow-up:active)::after { diff --git a/src/vs/sessions/contrib/chat/browser/scopedWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/scopedWorkspacePicker.ts index 31f59cfe4af54..6285305cdca05 100644 --- a/src/vs/sessions/contrib/chat/browser/scopedWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/scopedWorkspacePicker.ts @@ -20,6 +20,7 @@ import { ISessionsProvidersService } from '../../../services/sessions/browser/se 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'; /** * A simplified workspace picker that scopes its contents to the host @@ -46,6 +47,7 @@ export class ScopedWorkspacePicker extends WorkspacePicker { @IOutputService outputService: IOutputService, @IConfigurationService configurationService: IConfigurationService, @ICommandService commandService: ICommandService, + @IWorkspacesService workspacesService: IWorkspacesService, @IAgentHostFilterService private readonly _agentHostFilterService: IAgentHostFilterService, ) { super( @@ -61,6 +63,7 @@ export class ScopedWorkspacePicker extends WorkspacePicker { outputService, configurationService, commandService, + workspacesService, ); // When the scoped host changes, if the current selection no longer @@ -108,6 +111,7 @@ export class ScopedWorkspacePicker extends WorkspacePicker { items.push({ kind: ActionListItemKind.Action, label: workspace.label, + description: workspace.description, group: { title: '', icon: workspace.icon }, item: { selection, checked: this._isSelectedWorkspace(selection) || undefined }, onRemove: () => this._removeRecentWorkspace(selection), diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index c6efcb7192a2a..aad022e246614 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -34,6 +34,7 @@ import { ISessionsProvidersService } from '../../../services/sessions/browser/se 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'; const LEGACY_STORAGE_KEY_RECENT_PROJECTS = 'sessions.recentlyPickedProjects'; const STORAGE_KEY_RECENT_WORKSPACES = 'sessions.recentlyPickedWorkspaces'; @@ -90,6 +91,9 @@ export class WorkspacePicker extends Disposable { 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[] = []; + get selectedProject(): IWorkspaceSelection | undefined { return this._selectedWorkspace; } @@ -107,6 +111,7 @@ export class WorkspacePicker extends Disposable { @IOutputService private readonly outputService: IOutputService, @IConfigurationService private readonly configurationService: IConfigurationService, @ICommandService private readonly commandService: ICommandService, + @IWorkspacesService private readonly workspacesService: IWorkspacesService, ) { super(); @@ -140,6 +145,10 @@ export class WorkspacePicker extends Disposable { })); this._watchConnectionStatus(); + + // Load VS Code recent folders eagerly and refresh on changes + this._loadVSCodeRecentFolders(); + this._register(this.workspacesService.onDidChangeRecentlyOpened(() => this._loadVSCodeRecentFolders())); } /** @@ -220,8 +229,8 @@ export class WorkspacePicker extends Disposable { }; const listOptions = showFilter - ? { showFilter: true, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces..."), reserveSubmenuSpace: false } - : { reserveSubmenuSpace: false }; + ? { showFilter: true, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces..."), reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true } + : { reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true }; triggerElement.setAttribute('aria-expanded', 'true'); this.actionWidgetService.show( @@ -326,50 +335,54 @@ export class WorkspacePicker extends Disposable { // Collect recent workspaces from picker storage across all providers const allProviders = this.sessionsProvidersService.getProviders(); const providerIds = new Set(allProviders.map(p => p.id)); - const recentWorkspaces = this._getRecentWorkspaces().filter(w => providerIds.has(w.providerId)); - const hasMultipleProviders = allProviders.length > 1; - - if (hasMultipleProviders) { - // Group workspaces by provider, showing provider name as description on the first entry - const providersWithWorkspaces = allProviders.filter(p => recentWorkspaces.some(w => w.providerId === p.id)); - for (let pi = 0; pi < providersWithWorkspaces.length; pi++) { - const provider = providersWithWorkspaces[pi]; - const isOffline = this._isProviderUnavailable(provider.id); - const providerWorkspaces = recentWorkspaces.filter(w => w.providerId === provider.id); - for (let i = 0; i < providerWorkspaces.length; i++) { - const { workspace, providerId } = providerWorkspaces[i]; - const selection: IWorkspaceSelection = { providerId, workspace }; - const selected = this._isSelectedWorkspace(selection); - const description = i === 0 - ? (isOffline ? localize('workspacePicker.providerOffline', "{0} (Offline)", provider.label) : provider.label) - : (isOffline ? localize('workspacePicker.offline', "Offline") : undefined); - items.push({ - kind: ActionListItemKind.Action, - label: workspace.label, - description, - group: { title: '', icon: workspace.icon }, - item: { selection, checked: selected || undefined }, - onRemove: () => this._removeRecentWorkspace(selection), - }); - } - if (pi < providersWithWorkspaces.length - 1) { - items.push({ kind: ActionListItemKind.Separator, label: '' }); - } + const ownRecentWorkspaces = this._getRecentWorkspaces().filter(w => providerIds.has(w.providerId)); + + // Merge VS Code recent folders (resolved through providers, deduplicated) + const vsCodeRecents = this._getVSCodeRecentWorkspaces().filter(w => providerIds.has(w.providerId)); + const ownRecentCount = ownRecentWorkspaces.length; + const recentWorkspaces = [...ownRecentWorkspaces, ...vsCodeRecents]; + + // Build flat list of workspace entries with their group info + const workspaceEntries: { workspace: ISessionWorkspace; providerId: string; isOwnRecent: boolean; groupTitle: string }[] = []; + const providersWithWorkspaces = allProviders.filter(p => recentWorkspaces.some(w => w.providerId === p.id)); + for (const provider of providersWithWorkspaces) { + const isOffline = this._isProviderUnavailable(provider.id); + const providerWorkspaces = recentWorkspaces + .map((w, idx) => ({ ...w, isOwnRecent: idx < ownRecentCount })) + .filter(w => w.providerId === provider.id); + for (const { workspace, providerId, isOwnRecent } of providerWorkspaces) { + const groupName = workspace.group ?? provider.label; + const groupTitle = isOffline ? localize('workspacePicker.groupOffline', "{0} (Offline)", groupName) : groupName; + workspaceEntries.push({ workspace, providerId, isOwnRecent, groupTitle }); } - } else { - for (const { workspace, providerId } of recentWorkspaces) { - const selection: IWorkspaceSelection = { providerId, workspace }; - const selected = this._isSelectedWorkspace(selection); - const isOffline = this._isProviderUnavailable(providerId); - items.push({ - kind: ActionListItemKind.Action, - label: workspace.label, - description: isOffline ? localize('workspacePicker.offlineSingle', "Offline") : undefined, - group: { title: '', icon: workspace.icon }, - item: { selection, checked: selected || undefined }, - onRemove: () => this._removeRecentWorkspace(selection), - }); + } + + // Sort by group name, then by label within each group + workspaceEntries.sort((a, b) => { + const groupCmp = a.groupTitle.localeCompare(b.groupTitle); + if (groupCmp !== 0) { + return groupCmp; + } + return a.workspace.label.localeCompare(b.workspace.label); + }); + + // Add items with separators between groups + let lastGroupTitle: string | undefined; + for (const { workspace, providerId, isOwnRecent, groupTitle } of workspaceEntries) { + if (lastGroupTitle !== undefined && lastGroupTitle !== groupTitle) { + items.push({ kind: ActionListItemKind.Separator, label: '' }); } + lastGroupTitle = groupTitle; + const selection: IWorkspaceSelection = { providerId, workspace }; + const selected = this._isSelectedWorkspace(selection); + items.push({ + kind: ActionListItemKind.Action, + label: workspace.label, + description: workspace.description, + group: { title: groupTitle, icon: workspace.icon }, + item: { selection, checked: selected || undefined }, + onRemove: isOwnRecent ? () => this._removeRecentWorkspace(selection) : () => this._removeVSCodeRecentWorkspace(selection), + }); } // Browse actions from all providers @@ -380,7 +393,7 @@ export class WorkspacePicker extends Disposable { if (items.length > 0 && (allBrowseActions.length > 0 || remoteProviders.length > 0)) { items.push({ kind: ActionListItemKind.Separator, label: '' }); } - if (hasMultipleProviders && (allBrowseActions.length + remoteProviders.length) > 1) { + if (allProviders.length > 1 && (allBrowseActions.length + remoteProviders.length) > 1) { // Show a single "Select..." entry with provider-grouped submenu actions // that also includes remote host entries const providerMap = new Map(); @@ -861,6 +874,22 @@ export class WorkspacePicker extends Disposable { } } + protected _removeVSCodeRecentWorkspace(selection: IWorkspaceSelection): void { + const uri = selection.workspace.repositories[0]?.uri; + if (!uri) { + return; + } + this.workspacesService.removeRecentlyOpened([uri]); + + // Clear current selection if it was the removed workspace + if (this._isSelectedWorkspace(selection)) { + this.actionWidgetService.hide(); + this._selectedWorkspace = undefined; + this._updateTriggerLabel(); + this._onDidSelectWorkspace.fire(undefined); + } + } + private _getStoredRecentWorkspaces(): IStoredRecentWorkspace[] { const raw = this.storageService.get(STORAGE_KEY_RECENT_WORKSPACES, StorageScope.PROFILE); if (!raw) { @@ -873,4 +902,48 @@ export class WorkspacePicker extends Disposable { } } + // -- VS Code recent folders ----------------------------------------------- + + private async _loadVSCodeRecentFolders(): Promise { + const recentlyOpened = await this.workspacesService.getRecentlyOpened(); + this._vsCodeRecentFolderUris = recentlyOpened.workspaces + .filter(isRecentFolder) + .map(f => f.folderUri); + } + + /** + * Returns VS Code recent folders resolved through registered session + * providers, excluding any URIs already present in the sessions' own + * recent workspace history. + */ + protected _getVSCodeRecentWorkspaces(): { providerId: string; workspace: ISessionWorkspace }[] { + if (this._vsCodeRecentFolderUris.length === 0) { + return []; + } + + // Collect URIs already in sessions history to avoid duplicates + const ownRecents = this._getStoredRecentWorkspaces(); + const ownUris = new Set(ownRecents.map(r => URI.revive(r.uri).toString())); + + const providers = this.sessionsProvidersService.getProviders(); + const result: { providerId: string; workspace: ISessionWorkspace }[] = []; + + for (const folderUri of this._vsCodeRecentFolderUris) { + if (ownUris.has(folderUri.toString())) { + continue; + } + for (const provider of providers) { + if (this._isProviderUnavailable(provider.id)) { + continue; + } + const workspace = provider.resolveWorkspace(folderUri); + if (workspace) { + result.push({ providerId: provider.id, workspace }); + } + } + } + + return result; + } + } diff --git a/src/vs/sessions/contrib/chat/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts b/src/vs/sessions/contrib/chat/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts index a014ef88e119f..f7c73c88c40a7 100644 --- a/src/vs/sessions/contrib/chat/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts @@ -10,7 +10,7 @@ import { observableValue } from '../../../../../../base/common/observable.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; -import { IResolveSessionConfigResult, ISessionConfigPropertySchema } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; +import { ResolveSessionConfigResult, SessionConfigPropertySchema } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js'; import { AgentHostPermissionPickerDelegate, isWellKnownAutoApproveSchema } from '../../../browser/agentHost/agentHostPermissionPickerDelegate.js'; import { IAgentHostSessionsProvider } from '../../../../../common/agentHostSessionsProvider.js'; @@ -21,7 +21,7 @@ import { IActiveSession, ISessionsManagementService } from '../../../../../servi const PROVIDER_ID = 'local-agent-host'; const SESSION_ID = 'local-agent-host:s1'; -function makeWellKnownConfig(value: string | undefined): IResolveSessionConfigResult { +function makeWellKnownConfig(value: string | undefined): ResolveSessionConfigResult { return { schema: { type: 'object', @@ -36,7 +36,7 @@ function makeWellKnownConfig(value: string | undefined): IResolveSessionConfigRe }, }, values: value === undefined ? {} : { autoApprove: value }, - } as IResolveSessionConfigResult; + } as ResolveSessionConfigResult; } class FakeProvider implements Pick { @@ -44,10 +44,10 @@ class FakeProvider implements Pick(); readonly onDidChangeSessionConfig: Event = this._onDidChange.event; - config: IResolveSessionConfigResult | undefined; + config: ResolveSessionConfigResult | undefined; readonly setCalls: Array<[string, string, string]> = []; - getSessionConfig(_sessionId: string): IResolveSessionConfigResult | undefined { + getSessionConfig(_sessionId: string): ResolveSessionConfigResult | undefined { return this.config; } async setSessionConfigValue(sessionId: string, property: string, value: string): Promise { @@ -177,14 +177,14 @@ suite('AgentHostPermissionPickerDelegate', () => { suite('isWellKnownAutoApproveSchema', () => { ensureNoDisposablesAreLeakedInTestSuite(); - function schema(overrides: Partial = {}): ISessionConfigPropertySchema { + function schema(overrides: Partial = {}): SessionConfigPropertySchema { return { title: 'Auto Approve', description: 'desc', type: 'string', enum: ['default', 'autoApprove', 'autopilot'], ...overrides, - } as ISessionConfigPropertySchema; + } as SessionConfigPropertySchema; } test('matches the canonical three-value enum', () => { 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 93b7e37e9dbf7..052afa16a8373 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -27,6 +27,9 @@ import { IAgentHostSessionsProvider } from '../../../../common/agentHostSessions 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'; // ---- Storage key (must match the one in sessionWorkspacePicker.ts) ---------- const STORAGE_KEY_RECENT_WORKSPACES = 'sessions.recentlyPickedWorkspaces'; @@ -70,6 +73,7 @@ function createMockProvider(id: string, opts?: { onDidChangeSessionConfig: Event.None, getSessionConfig: () => undefined, setSessionConfigValue: async () => { }, + replaceSessionConfig: async () => { }, getSessionConfigCompletions: async () => [], getCreateSessionConfig: () => undefined, clearSessionConfig: () => { }, @@ -137,6 +141,12 @@ function createTestPicker( instantiationService.stub(IClipboardService, {}); instantiationService.stub(IPreferencesService, {}); instantiationService.stub(IOutputService, {}); + instantiationService.stub(IConfigurationService, { getValue: () => undefined }); + instantiationService.stub(ICommandService, { executeCommand: async () => { } }); + instantiationService.stub(IWorkspacesService, { + getRecentlyOpened: async () => ({ workspaces: [], files: [] }), + onDidChangeRecentlyOpened: Event.None, + }); return disposables.add(instantiationService.createInstance(WorkspacePicker)); } @@ -297,9 +307,11 @@ suite('WorkspacePicker - Connection Status', () => { const picker = createTestPicker(disposables, providersService, storage); // Select the local workspace + const resolvedWorkspace = localProvider.resolveWorkspace(URI.file('/local/project')); + assert.ok(resolvedWorkspace, 'resolveWorkspace should resolve file:// URIs'); const localWorkspace: IWorkspaceSelection = { providerId: 'local-1', - workspace: localProvider.resolveWorkspace(URI.file('/local/project')), + workspace: resolvedWorkspace, }; picker.setSelectedWorkspace(localWorkspace, false); diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts index ce3a51ea72975..c1b06e5c11ea2 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -88,11 +88,11 @@ Registry.as(Extensions.Configuration).registerDefaultCon 'workbench.editor.doubleClickTabToToggleEditorGroupSizes': 'maximize', 'workbench.editor.restoreEditors': false, 'update.showReleaseNotes': false, - 'workbench.notifications.position': 'top-right', + 'workbench.notifications.position': 'bottom-right', 'workbench.startupEditor': 'none', 'workbench.tips.enabled': false, 'workbench.layoutControl.type': 'toggles', - 'workbench.editor.useModal': 'some', + 'workbench.editor.useModal': 'all', 'workbench.panel.showLabels': false, 'workbench.colorTheme': ThemeSettingDefaults.COLOR_THEME_DARK, diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/claudePermissionModePicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/claudePermissionModePicker.ts index 70e56549575d0..99ee2d015f208 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/claudePermissionModePicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/claudePermissionModePicker.ts @@ -101,7 +101,7 @@ export class ClaudePermissionModePicker extends Disposable { group: { kind: ActionListItemKind.Header, title: '', icon: mode.icon }, item: mode, label: mode.label, - description: mode.description, + detail: mode.description, disabled: false, })); @@ -114,7 +114,7 @@ export class ClaudePermissionModePicker extends Disposable { onHide: () => { triggerElement.focus(); }, }; - const listOptions: IActionListOptions = { descriptionBelow: true, minWidth: 255 }; + const listOptions: IActionListOptions = { minWidth: 255 }; this.actionWidgetService.show( 'claudePermissionModePicker', false, diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 2fa9bf38d422d..ce56676f942b7 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -9,6 +9,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { CancellationError } from '../../../../base/common/errors.js'; import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; import { autorun, constObservable, derived, IObservable, IReader, observableFromEvent, observableValue, transaction } from '../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; @@ -22,9 +23,9 @@ import { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench import { IChatService, IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatResponseModel } from '../../../../workbench/contrib/chat/common/model/chatModel.js'; import { ChatSessionStatus, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { ISession, IChat, ISessionRepository, ISessionWorkspace, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, CopilotCLISessionType, CopilotCloudSessionType, ClaudeCodeSessionType, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange } from '../../../services/sessions/common/session.js'; +import { ISession, IChat, ISessionRepository, ISessionWorkspace, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, CopilotCLISessionType, CopilotCloudSessionType, ClaudeCodeSessionType, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, toSessionId } from '../../../services/sessions/common/session.js'; import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; -import { basename, isEqual } from '../../../../base/common/resources.js'; +import { basename, dirname, isEqual } from '../../../../base/common/resources.js'; import { ISendRequestOptions, ISessionChangeEvent, ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js'; import { ISessionOptionGroup } from '../../chat/browser/newSession.js'; import { IsolationMode } from './isolationPicker.js'; @@ -39,6 +40,7 @@ import { IContextKeyService, ContextKeyExpr } from '../../../../platform/context import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IGitHubService } from '../../github/browser/githubService.js'; import { computePullRequestIcon, GitHubPullRequestState } from '../../github/common/types.js'; @@ -232,7 +234,7 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { @IGitService private readonly gitService: IGitService, ) { super(); - this.id = `${providerId}:${resource.toString()}`; + this.id = toSessionId(providerId, resource); this.providerId = providerId; this.sessionType = AgentSessionProviders.Background; this.icon = CopilotCLISessionType.icon; @@ -516,7 +518,7 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession @IContextKeyService private readonly contextKeyService: IContextKeyService, ) { super(); - this.id = `${providerId}:${resource.toString()}`; + this.id = toSessionId(providerId, resource); this.providerId = providerId; this.sessionType = target; this.icon = CopilotCloudSessionType.icon; @@ -743,7 +745,7 @@ class ClaudeCodeNewSession extends Disposable implements ICopilotChatSession { providerId: string, ) { super(); - this.id = `${providerId}:${resource.toString()}`; + this.id = toSessionId(providerId, resource); this.providerId = providerId; this.sessionType = AgentSessionProviders.Claude; this.icon = ClaudeCodeSessionType.icon; @@ -874,7 +876,7 @@ class AgentSessionAdapter implements ICopilotChatSession { providerId: string, private readonly _gitHubService: IGitHubService | undefined, ) { - this.id = `${providerId}:${session.resource.toString()}`; + this.id = toSessionId(providerId, session.resource); this.resource = session.resource; this.providerId = providerId; this.sessionType = session.providerType; @@ -1242,6 +1244,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions @IConfigurationService private readonly configurationService: IConfigurationService, @ILogService private readonly logService: ILogService, @IGitHubService private readonly gitHubService: IGitHubService, + @ILabelService private readonly labelService: ILabelService, ) { super(); @@ -1334,6 +1337,9 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } const workspace = this.resolveWorkspace(workspaceUri); + if (!workspace) { + throw new Error(`Cannot resolve workspace for URI: ${workspaceUri.toString()}`); + } if (workspaceUri.scheme === GITHUB_REMOTE_FILE_SCHEME) { if (sessionTypeId !== CopilotCloudSessionType.id) { @@ -2015,7 +2021,10 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions this._currentNewSession = undefined; } - const newWorkspace: ISessionWorkspace = this.resolveWorkspace(repository.workingDirectory || repository.uri); + const newWorkspace = this.resolveWorkspace(repository.workingDirectory || repository.uri); + if (!newWorkspace) { + throw new Error(`Cannot resolve workspace for URI: ${(repository.workingDirectory || repository.uri).toString()}`); + } const resource = URI.from({ scheme: AgentSessionProviders.Background, path: `/untitled-${generateUuid()}` }); const session = this.instantiationService.createInstance(CopilotCLISession, resource, newWorkspace, this.id); session.setIsolationMode('workspace'); @@ -2170,9 +2179,16 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return undefined; } - resolveWorkspace(repositoryUri: URI): ISessionWorkspace { + resolveWorkspace(repositoryUri: URI): ISessionWorkspace | undefined { + if (repositoryUri.scheme !== Schemas.file && repositoryUri.scheme !== GITHUB_REMOTE_FILE_SCHEME) { + return undefined; + } return { label: this._labelFromUri(repositoryUri), + description: this._descriptionFromUri(repositoryUri), + group: repositoryUri.scheme === GITHUB_REMOTE_FILE_SCHEME + ? localize('copilotProvider.workspaceGroupRepositories', "Repositories") + : localize('copilotProvider.workspaceGroupFolders', "Folders"), icon: this._iconFromUri(repositoryUri), repositories: [{ uri: repositoryUri, workingDirectory: undefined, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }], requiresWorkspaceTrust: repositoryUri.scheme !== GITHUB_REMOTE_FILE_SCHEME @@ -2186,6 +2202,16 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return basename(uri); } + private _descriptionFromUri(uri: URI): string | undefined { + if (uri.scheme === GITHUB_REMOTE_FILE_SCHEME) { + // For GitHub URIs the path is "//", return the owner as description + const parts = uri.path.substring(1).split('/'); + return parts.length >= 2 ? parts[0] : undefined; + } + // For local file URIs, return the tildified parent directory path + return this.labelService.getUriLabel(dirname(uri), { relative: false }); + } + private _iconFromUri(uri: URI): ThemeIcon { if (uri.scheme === GITHUB_REMOTE_FILE_SCHEME) { return Codicon.repo; diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts index ea75265e07a07..135d30996ecbc 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts @@ -154,7 +154,7 @@ export class PermissionPicker extends Disposable { checked: this._currentLevel === ChatPermissionLevel.Default, }, label: localize('permissions.default', "Default Approvals"), - description: localize('permissions.default.subtext', "Copilot uses your configured settings"), + detail: localize('permissions.default.subtext', "Copilot uses your configured settings"), disabled: false, }, { @@ -167,7 +167,7 @@ export class PermissionPicker extends Disposable { checked: this._currentLevel === ChatPermissionLevel.AutoApprove, }, label: localize('permissions.autoApprove', "Bypass Approvals"), - description: localize('permissions.autoApprove.subtext', "All tool calls are auto-approved"), + detail: localize('permissions.autoApprove.subtext', "All tool calls are auto-approved"), disabled: policyRestricted, }, ]; @@ -183,7 +183,7 @@ export class PermissionPicker extends Disposable { checked: this._currentLevel === ChatPermissionLevel.Autopilot, }, label: localize('permissions.autopilot', "Autopilot (Preview)"), - description: localize('permissions.autopilot.subtext', "Autonomously iterates from start to finish"), + detail: localize('permissions.autopilot.subtext', "Autonomously iterates from start to finish"), disabled: policyRestricted, }); } @@ -219,7 +219,7 @@ export class PermissionPicker extends Disposable { onHide: () => { triggerElement.focus(); }, }; - const listOptions: IActionListOptions = { descriptionBelow: true, minWidth: 255 }; + const listOptions: IActionListOptions = { minWidth: 255 }; this.actionWidgetService.show( 'permissionPicker', false, diff --git a/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index 3aa430aa68908..44920cd0af8fc 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -33,6 +33,7 @@ import { ISessionChangeEvent } from '../../../../services/sessions/common/sessio import { ClaudeCodeSessionType, CopilotCLISessionType, GITHUB_REMOTE_FILE_SCHEME, SessionStatus } from '../../../../services/sessions/common/session.js'; import { CLAUDE_CODE_ENABLED_SETTING, CopilotChatSessionsProvider, COPILOT_PROVIDER_ID } from '../../browser/copilotChatSessionsProvider.js'; import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; // ---- Helpers ---------------------------------------------------------------- @@ -179,6 +180,9 @@ function createProviderWithConfig( }); // Stub IInstantiationService so provider can use createInstance for CopilotCLISession instantiationService.stub(IInstantiationService, instantiationService); + instantiationService.stub(ILabelService, { + getUriLabel: (uri: URI) => uri.path, + }); const provider = disposables.add(instantiationService.createInstance(CopilotChatSessionsProvider)); return { provider, configService }; @@ -248,6 +252,9 @@ function createProviderForSendTests( instantiationService.stub(ILanguageModelToolsService, { toToolReferences: () => [] }); instantiationService.stub(IGitService, { openRepository: async () => undefined }); instantiationService.stub(IInstantiationService, instantiationService); + instantiationService.stub(ILabelService, { + getUriLabel: (uri: URI) => uri.path, + }); return disposables.add(instantiationService.createInstance(CopilotChatSessionsProvider)); } @@ -855,6 +862,7 @@ suite('CopilotChatSessionsProvider', () => { const workspace = provider.resolveWorkspace(uri); + assert.ok(workspace, 'resolveWorkspace should resolve file:// URIs'); assert.strictEqual(workspace.label, 'project'); assert.strictEqual(workspace.repositories.length, 1); assert.strictEqual(workspace.repositories[0].uri.toString(), uri.toString()); diff --git a/src/vs/sessions/contrib/editor/browser/editor.contribution.ts b/src/vs/sessions/contrib/editor/browser/editor.contribution.ts index 0d6e2ab4bd97f..7fa989f72789b 100644 --- a/src/vs/sessions/contrib/editor/browser/editor.contribution.ts +++ b/src/vs/sessions/contrib/editor/browser/editor.contribution.ts @@ -124,9 +124,12 @@ class OpenEditorInModalEditorAction extends Action2 { async run(accessor: ServicesAccessor): Promise { const viewsService = accessor.get(IViewsService); + const layoutService = accessor.get(IAgentWorkbenchLayoutService); const configurationService = accessor.get(IConfigurationService); const editorGroupsService = accessor.get(IEditorGroupsService); + const isMaximized = layoutService.isEditorMaximized(); + // Set the `workbench.editor.useModal` setting to 'all' await configurationService.updateValue('workbench.editor.useModal', 'all'); @@ -150,6 +153,13 @@ class OpenEditorInModalEditorAction extends Action2 { const modalPart = await editorGroupsService.createModalEditorPart(); const editorsToMove = prepareMoveCopyEditors(activeGroup, activeGroup.editors.slice(), true); activeGroup.moveEditors(editorsToMove, modalPart.activeGroup); + + // Maximize + if (isMaximized) { + modalPart.toggleMaximized(); + } + + // Focus modalPart.activeGroup.focus(); } } @@ -183,11 +193,14 @@ class OpenModalEditorInEditorAction extends Action2 { const editorGroupsService = accessor.get(IEditorGroupsService); const layoutService = accessor.get(IAgentWorkbenchLayoutService); - const activeGroup = editorGroupsService.activeModalEditorPart?.activeGroup; - if (!activeGroup) { + const activeEditorPart = editorGroupsService.activeModalEditorPart; + const activeGroup = activeEditorPart?.activeGroup; + if (!activeEditorPart || !activeGroup) { return; } + const isMaximized = activeEditorPart.maximized; + // Set the `workbench.editor.useModal` setting back to 'some' await configurationService.updateValue('workbench.editor.useModal', 'some'); @@ -213,6 +226,14 @@ class OpenModalEditorInEditorAction extends Action2 { // Move all remaining editors to the main editor part await commandService.executeCommand(MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID); + + // Maximize + if (isMaximized) { + layoutService.setEditorMaximized(true); + } + + // Focus + editorGroupsService.activeGroup.focus(); } } diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 518c56e1df459..e3222a3232e0f 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -12,8 +12,8 @@ import { agentHostAuthority } from '../../../../platform/agentHost/common/agentH import { type AgentProvider, type IAgentConnection } from '../../../../platform/agentHost/common/agentService.js'; import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostEntry, IRemoteAgentHostService, RemoteAgentHostAutoConnectSettingId, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, getEntryAddress } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { TunnelAgentHostsSettingId } from '../../../../platform/agentHost/common/tunnelAgentHost.js'; -import { type IProtectedResourceMetadata, type URI as ProtocolURI } from '../../../../platform/agentHost/common/state/protocol/state.js'; -import { type IAgentInfo, type ICustomizationRef, type IRootState } from '../../../../platform/agentHost/common/state/sessionState.js'; +import { type ProtectedResourceMetadata, type URI as ProtocolURI } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import { type AgentInfo, type CustomizationRef, type RootState } from '../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; @@ -307,7 +307,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc } } - private _handleRootStateChange(address: string, loggedConnection: LoggingAgentConnection, rootState: IRootState): void { + private _handleRootStateChange(address: string, loggedConnection: LoggingAgentConnection, rootState: RootState): void { const connState = this._connections.get(address); if (!connState) { return; @@ -338,7 +338,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc } } - private _registerAgent(address: string, loggedConnection: LoggingAgentConnection, agent: IAgentInfo, configuredName: string | undefined): void { + private _registerAgent(address: string, loggedConnection: LoggingAgentConnection, agent: AgentInfo, configuredName: string | undefined): void { const connState = this._connections.get(address); if (!connState) { return; @@ -408,7 +408,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc const bundler = agentStore.add(this._instantiationService.createInstance(SyncedCustomizationBundler, sessionType)); // Agent-level customizations observable - const customizations = observableValue('agentCustomizations', []); + const customizations = observableValue('agentCustomizations', []); const updateCustomizations = async () => { const refs = await this._resolveCustomizations(syncProvider, bundler); customizations.set(refs, undefined); @@ -460,14 +460,14 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc private async _resolveCustomizations( syncProvider: AgentCustomizationSyncProvider, bundler: SyncedCustomizationBundler, - ): Promise { + ): Promise { const entries = syncProvider.getSelectedEntries(); if (entries.length === 0) { return []; } const plugins = this._agentPluginService.plugins.get(); - const refs: ICustomizationRef[] = []; + const refs: CustomizationRef[] = []; const individualFiles: { uri: URI; type: PromptsType }[] = []; for (const entry of entries) { @@ -508,7 +508,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc * Marks the matching provider's `authenticationPending` observable while * the auth pass is in flight so that sessions surface as still loading. */ - private async _authenticateWithConnection(address: string, loggedConnection: LoggingAgentConnection, agents: readonly IAgentInfo[]): Promise { + private async _authenticateWithConnection(address: string, loggedConnection: LoggingAgentConnection, agents: readonly AgentInfo[]): Promise { const providerId = `agenthost-${agentHostAuthority(address)}`; const provider = this._sessionsProvidersService.getProvider(providerId); provider?.setAuthenticationPending(true); @@ -545,7 +545,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc * Interactively prompt the user to authenticate when the server requires it. * Returns true if authentication succeeded. */ - private async _resolveAuthenticationInteractively(loggedConnection: LoggingAgentConnection, protectedResources: readonly IProtectedResourceMetadata[]): Promise { + private async _resolveAuthenticationInteractively(loggedConnection: LoggingAgentConnection, protectedResources: readonly ProtectedResourceMetadata[]): Promise { try { for (const resource of protectedResources) { for (const server of resource.authorization_servers ?? []) { diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts index 840aa2b39d552..fa15661c08422 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts @@ -362,7 +362,7 @@ async function promptForRemoteFolder( const sessionsProvidersService = accessor.get(ISessionsProvidersService); const sessionsManagementService = accessor.get(ISessionsManagementService); - // The provider is created synchronously during addSSHConnection's + // The provider is created synchronously during addManagedConnection's // onDidChangeConnections event, so it should exist by now. const provider = sessionsProvidersService.getProviders().find((p): p is IAgentHostSessionsProvider => isAgentHostProvider(p) && p.remoteAddress === connection.localAddress); if (!provider) { diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts index 10bed14b9f989..58ef0bf07a00b 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts @@ -15,7 +15,7 @@ import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyn import { type IHarnessDescriptor, type ICustomizationItem, type ICustomizationItemProvider } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import type { IAgentConnection } from '../../../../platform/agentHost/common/agentService.js'; import { ActionType } from '../../../../platform/agentHost/common/state/sessionActions.js'; -import { type IAgentInfo, type ICustomizationRef, type ISessionCustomization, CustomizationStatus } from '../../../../platform/agentHost/common/state/sessionState.js'; +import { type AgentInfo, type CustomizationRef, type SessionCustomization, CustomizationStatus } from '../../../../platform/agentHost/common/state/sessionState.js'; import { BUILTIN_STORAGE } from '../../chat/common/builtinPromptsStorage.js'; import { AgentCustomizationSyncProvider } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationSyncProvider.js'; @@ -39,20 +39,20 @@ function toStatusString(status: CustomizationStatus | undefined): 'loading' | 'l * Provider that exposes a remote agent's customizations as * {@link ICustomizationItem} entries for the list widget. * - * Baseline items come from {@link IAgentInfo.customizations} (available + * Baseline items come from {@link AgentInfo.customizations} (available * without an active session). When a session is active, the provider - * overlays {@link ISessionCustomization} data, which includes loading + * overlays {@link SessionCustomization} data, which includes loading * status and enabled state. */ export class RemoteAgentCustomizationItemProvider extends Disposable implements ICustomizationItemProvider { private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; - private _agentCustomizations: readonly ICustomizationRef[]; - private _sessionCustomizations: readonly ISessionCustomization[] | undefined; + private _agentCustomizations: readonly CustomizationRef[]; + private _sessionCustomizations: readonly SessionCustomization[] | undefined; constructor( - agentInfo: IAgentInfo, + agentInfo: AgentInfo, connection: IAgentConnection, ) { super(); @@ -61,7 +61,7 @@ export class RemoteAgentCustomizationItemProvider extends Disposable implements // Listen for customization changes from any session via action events this._register(connection.onDidAction(envelope => { if (envelope.action.type === ActionType.SessionCustomizationsChanged) { - const customizations = (envelope.action as { customizations?: ISessionCustomization[] }).customizations; + const customizations = (envelope.action as { customizations?: SessionCustomization[] }).customizations; if (customizations && customizations !== this._sessionCustomizations) { this._sessionCustomizations = customizations; this._onDidChange.fire(); @@ -74,7 +74,7 @@ export class RemoteAgentCustomizationItemProvider extends Disposable implements * Updates the baseline agent customizations (e.g. when root state * changes and agent info is refreshed). */ - updateAgentCustomizations(customizations: readonly ICustomizationRef[]): void { + updateAgentCustomizations(customizations: readonly CustomizationRef[]): void { this._agentCustomizations = customizations; this._onDidChange.fire(); } diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 64088fd955fba..91155f2bde17c 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -8,7 +8,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; -import { basename } from '../../../../base/common/resources.js'; +import { basename, dirname } from '../../../../base/common/resources.js'; import { IObservable, observableValue } from '../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; @@ -18,6 +18,7 @@ import { AGENT_HOST_SCHEME, agentHostAuthority, toAgentHostUri } from '../../../ import { AgentSession, type IAgentConnection, type IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js'; import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; @@ -183,6 +184,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid @IChatWidgetService chatWidgetService: IChatWidgetService, @ILanguageModelsService languageModelsService: ILanguageModelsService, @IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService, + @ILabelService private readonly _labelService: ILabelService, ) { super(chatSessionsService, chatService, chatWidgetService, languageModelsService); @@ -242,8 +244,11 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid protected _adapterOptions() { return { description: new MarkdownString().appendText(this.label), - buildWorkspace: (project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined) => - RemoteAgentHostSessionsProvider.buildWorkspace(project, workingDirectory, 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 }); + }, }; } @@ -484,21 +489,22 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid // -- Workspaces ---------------------------------------------------------- - static buildWorkspace(project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined, providerLabel: string): ISessionWorkspace | undefined { - return buildAgentHostSessionWorkspace(project, workingDirectory, { providerLabel, fallbackIcon: Codicon.remote, requiresWorkspaceTrust: false }); - } - private _buildWorkspaceFromUri(uri: URI): ISessionWorkspace { const folderName = basename(uri) || uri.path; return { label: `${folderName} [${this.label}]`, + description: this._labelService.getUriLabel(dirname(uri), { relative: false }), + group: this.label, icon: Codicon.remote, repositories: [{ uri, workingDirectory: undefined, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }], requiresWorkspaceTrust: true, }; } - resolveWorkspace(repositoryUri: URI): ISessionWorkspace { + resolveWorkspace(repositoryUri: URI): ISessionWorkspace | undefined { + if (repositoryUri.scheme !== AGENT_HOST_SCHEME) { + return undefined; + } return this._buildWorkspaceFromUri(repositoryUri); } diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/webTunnelAgentHostService.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/webTunnelAgentHostService.ts index 5b4a1b8f4a8b1..ceab497869a22 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/webTunnelAgentHostService.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/webTunnelAgentHostService.ts @@ -8,7 +8,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { RemoteAgentHostProtocolClient } from '../../../../platform/agentHost/browser/remoteAgentHostProtocolClient.js'; import { RemoteAgentHostEntryType, IRemoteAgentHostService, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import type { IProtocolTransport } from '../../../../platform/agentHost/common/state/sessionTransport.js'; -import type { IProtocolMessage, IAhpServerNotification, IJsonRpcResponse } from '../../../../platform/agentHost/common/state/sessionProtocol.js'; +import type { ProtocolMessage, AhpServerNotification, JsonRpcResponse } from '../../../../platform/agentHost/common/state/sessionProtocol.js'; import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../../../../platform/agentHost/common/transportConstants.js'; import { ITunnelAgentHostService, @@ -141,7 +141,7 @@ export class WebTunnelAgentHostService extends Disposable implements ITunnelAgen await protocolClient.connect(); this._logService.info(`${LOG_PREFIX} Protocol handshake completed with ${address}`); - await this._remoteAgentHostService.addSSHConnection({ + await this._remoteAgentHostService.addManagedConnection({ name: tunnel.name, connectionToken, connection: { @@ -228,7 +228,7 @@ export class WebTunnelAgentHostService extends Disposable implements ITunnelAgen * so there is no `connect()` method — the protocol client skips that step. */ class TunnelConnectionTransport extends Disposable implements IProtocolTransport { - private readonly _onMessage = this._register(new Emitter()); + private readonly _onMessage = this._register(new Emitter()); readonly onMessage = this._onMessage.event; private readonly _onClose = this._register(new Emitter()); @@ -242,9 +242,9 @@ class TunnelConnectionTransport extends Disposable implements IProtocolTransport ) { super(); this._register(_connection.onMessage((data: string) => { - let message: IProtocolMessage; + let message: ProtocolMessage; try { - message = JSON.parse(data) as IProtocolMessage; + message = JSON.parse(data) as ProtocolMessage; } catch (err) { this._malformedFrames++; if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) { @@ -269,7 +269,7 @@ class TunnelConnectionTransport extends Disposable implements IProtocolTransport })); } - send(message: IProtocolMessage | IAhpServerNotification | IJsonRpcResponse): void { + send(message: ProtocolMessage | AhpServerNotification | JsonRpcResponse): void { this._connection.send(JSON.stringify(message)); } diff --git a/src/vs/sessions/contrib/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts b/src/vs/sessions/contrib/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts index 039d471bc16d8..07c9214622c12 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts @@ -105,7 +105,7 @@ export class TunnelAgentHostService extends Disposable implements ITunnelAgentHo await protocolClient.connect(); this._logService.info(`${LOG_PREFIX} Protocol handshake completed with ${result.address}`); - await this._remoteAgentHostService.addSSHConnection({ + await this._remoteAgentHostService.addManagedConnection({ name: result.name, connectionToken: result.connectionToken, connection: { 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 d4c016225fa9b..7799f9eabbe48 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -12,11 +12,11 @@ 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 { ISessionAction, ITerminalAction } from '../../../../../platform/agentHost/common/state/protocol/action-origin.generated.js'; -import type { IResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; +import type { 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 IAgentInfo, type IModelSelection, type IRootState, type ISessionConfigState, type ISessionState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; -import { ActionType, type IActionEnvelope, type INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js'; +import { SessionLifecycle, type AgentInfo, type ModelSelection, type RootState, type SessionConfigState, type SessionState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ActionType, type ActionEnvelope, type INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionStatus as ProtocolSessionStatus, StateComponents } from '../../../../../platform/agentHost/common/state/sessionState.js'; import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; @@ -30,27 +30,28 @@ import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/co import { ISessionChangeEvent } from '../../../../services/sessions/common/sessionsProvider.js'; import { SessionStatus, COPILOT_CLI_SESSION_TYPE } from '../../../../services/sessions/common/session.js'; import { RemoteAgentHostSessionsProvider, type IRemoteAgentHostSessionsProviderConfig } from '../../browser/remoteAgentHostSessionsProvider.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; // ---- Mock connection -------------------------------------------------------- class MockAgentConnection extends mock() { declare readonly _serviceBrand: undefined; - private readonly _onDidAction = new Emitter(); + private readonly _onDidAction = new Emitter(); override readonly onDidAction = this._onDidAction.event; private readonly _onDidNotification = new Emitter(); override readonly onDidNotification = this._onDidNotification.event; - private readonly _onDidRootStateChange = new Emitter(); - private _rootStateValue: IRootState = { agents: [{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as IAgentInfo] }; - override readonly rootState: IAgentSubscription; + private readonly _onDidRootStateChange = new Emitter(); + private _rootStateValue: RootState = { agents: [{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo] }; + override readonly rootState: IAgentSubscription; override readonly clientId = 'test-client-1'; private readonly _sessions = new Map(); public disposedSessions: URI[] = []; - public dispatchedActions: { action: ISessionAction | ITerminalAction; clientId: string; clientSeq: number }[] = []; + public dispatchedActions: { action: SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = []; public failResolveSessionConfig = false; - public resolveSessionConfigResult: IResolveSessionConfigResult = { schema: { type: 'object', properties: {} }, values: { isolation: 'worktree' } }; + public resolveSessionConfigResult: ResolveSessionConfigResult = { schema: { type: 'object', properties: {} }, values: { isolation: 'worktree' } }; private _nextSeq = 0; @@ -80,7 +81,7 @@ class MockAgentConnection extends mock() { this._sessions.delete(rawId); } - override async resolveSessionConfig(): Promise { + override async resolveSessionConfig(): Promise { await Promise.resolve(); if (this.failResolveSessionConfig) { throw new Error('resolveSessionConfig unavailable'); @@ -88,11 +89,11 @@ class MockAgentConnection extends mock() { return this.resolveSessionConfigResult; } - dispatchAction(action: ISessionAction | ITerminalAction, clientId: string, clientSeq: number): void { + dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void { this.dispatchedActions.push({ action, clientId, clientSeq }); } - override dispatch(action: ISessionAction | ITerminalAction): void { + override dispatch(action: SessionAction | TerminalAction): void { this.dispatchedActions.push({ action, clientId: this.clientId, clientSeq: this._nextSeq++ }); } @@ -103,8 +104,8 @@ class MockAgentConnection extends mock() { // ---- Session-state subscriptions --------------------------------------- - private readonly _sessionStateEmitters = new Map>(); - private readonly _sessionStateValues = new Map(); + private readonly _sessionStateEmitters = new Map>(); + private readonly _sessionStateValues = new Map(); public sessionSubscribeCounts = new Map(); public sessionUnsubscribeCounts = new Map(); @@ -113,7 +114,7 @@ class MockAgentConnection extends mock() { this.sessionSubscribeCounts.set(key, (this.sessionSubscribeCounts.get(key) ?? 0) + 1); let emitter = this._sessionStateEmitters.get(key); if (!emitter) { - emitter = new Emitter(); + emitter = new Emitter(); this._sessionStateEmitters.set(key, emitter); } const self = this; @@ -132,13 +133,13 @@ class MockAgentConnection extends mock() { }; } - setSessionState(rawId: string, provider: string, state: ISessionState): void { + setSessionState(rawId: string, provider: string, state: SessionState): void { const key = AgentSession.uri(provider, rawId).toString(); this._sessionStateValues.set(key, state); this._sessionStateEmitters.get(key)?.fire(state); } - setAgents(agents: IAgentInfo[]): void { + setAgents(agents: AgentInfo[]): void { this._rootStateValue = { agents }; this._onDidRootStateChange.fire(this._rootStateValue); } @@ -147,7 +148,7 @@ class MockAgentConnection extends mock() { this._onDidNotification.fire(n); } - fireAction(envelope: IActionEnvelope): void { + fireAction(envelope: ActionEnvelope): void { this._onDidAction.fire(envelope); } @@ -196,6 +197,9 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne lookupLanguageModel: () => undefined, }); instantiationService.stub(IStorageService, overrides?.storageService ?? disposables.add(new InMemoryStorageService())); + instantiationService.stub(ILabelService, { + getUriLabel: (uri: URI) => uri.path, + }); const config: IRemoteAgentHostSessionsProviderConfig = { address: overrides?.address ?? 'localhost:4321', @@ -209,7 +213,7 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne return provider; } -async function waitForSessionConfig(provider: RemoteAgentHostSessionsProvider, sessionId: string, predicate: (config: IResolveSessionConfigResult | undefined) => boolean): Promise { +async function waitForSessionConfig(provider: RemoteAgentHostSessionsProvider, sessionId: string, predicate: (config: ResolveSessionConfigResult | undefined) => boolean): Promise { if (predicate(provider.getSessionConfig(sessionId))) { return; } @@ -288,8 +292,8 @@ suite('RemoteAgentHostSessionsProvider', () => { disposables.add(provider.onDidChangeSessionTypes!(() => changes++)); connection.setAgents([ - { provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as IAgentInfo, - { provider: 'openai', displayName: 'OpenAI', description: '', models: [] } as IAgentInfo, + { provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo, + { provider: 'openai', displayName: 'OpenAI', description: '', models: [] } as AgentInfo, ]); assert.strictEqual(changes, 1); @@ -312,6 +316,7 @@ suite('RemoteAgentHostSessionsProvider', () => { 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.repositories.length, 1); assert.strictEqual(ws.repositories[0].uri.toString(), uri.toString()); @@ -344,8 +349,8 @@ suite('RemoteAgentHostSessionsProvider', () => { test('session added notifications ingest any advertised agent provider', () => runWithFakedTimers({ useFakeTimers: true }, async () => { connection.setAgents([ - { provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as IAgentInfo, - { provider: 'openai', displayName: 'OpenAI', description: '', models: [] } as IAgentInfo, + { provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo, + { provider: 'openai', displayName: 'OpenAI', description: '', models: [] } as AgentInfo, ]); const provider = createProvider(disposables, connection); @@ -665,7 +670,7 @@ suite('RemoteAgentHostSessionsProvider', () => { }, serverSeq: 1, origin: undefined, - } as IActionEnvelope); + } as ActionEnvelope); assert.strictEqual(target!.title.get(), 'Server Title'); assert.strictEqual(changes.length, 1); @@ -686,11 +691,11 @@ suite('RemoteAgentHostSessionsProvider', () => { action: { type: ActionType.SessionModelChanged, session: AgentSession.uri('copilotcli', 'model-change').toString(), - model: { id: 'new-model' } satisfies IModelSelection, + model: { id: 'new-model' } satisfies ModelSelection, }, serverSeq: 1, origin: undefined, - } as IActionEnvelope); + } as ActionEnvelope); assert.strictEqual(target!.modelId.get(), 'remote-localhost__4321-copilotcli:new-model'); assert.strictEqual(changes.length, 1); @@ -721,7 +726,7 @@ suite('RemoteAgentHostSessionsProvider', () => { }, serverSeq: 1, origin: undefined, - } as IActionEnvelope); + } as ActionEnvelope); await timeout(0); @@ -912,7 +917,7 @@ suite('RemoteAgentHostSessionsProvider', () => { }, serverSeq: 1, origin: undefined, - } as IActionEnvelope); + } as ActionEnvelope); await timeout(0); @@ -921,9 +926,9 @@ suite('RemoteAgentHostSessionsProvider', () => { assert.ok(updatedSession, 'Session should have updated title'); })); - // ---- Running session config seeding (from ISessionState.config) ------- + // ---- Running session config seeding (from SessionState.config) ------- - test('getSessionConfig seeds running config from session state subscription, filtered to sessionMutable properties', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + test('getSessionConfig seeds running config from session state subscription with full schema', () => runWithFakedTimers({ useFakeTimers: true }, async () => { connection.addSession(createSession('seed-1', { summary: 'Seeded Session' })); const provider = createProvider(disposables, connection); provider.getSessions(); @@ -933,7 +938,7 @@ suite('RemoteAgentHostSessionsProvider', () => { assert.strictEqual(provider.getSessionConfig(session!.sessionId), undefined); - const config: ISessionConfigState = { + const config: SessionConfigState = { schema: { type: 'object', properties: { @@ -943,7 +948,7 @@ suite('RemoteAgentHostSessionsProvider', () => { }, values: { autoApprove: 'default', isolation: 'worktree' }, }; - const fakeState: ISessionState = { + const fakeState: SessionState = { summary: { resource: AgentSession.uri('copilotcli', 'seed-1').toString(), provider: 'copilotcli', title: 'Seeded Session', status: ProtocolSessionStatus.Idle, createdAt: 0, modifiedAt: 0 }, lifecycle: SessionLifecycle.Ready, turns: [], @@ -953,13 +958,15 @@ suite('RemoteAgentHostSessionsProvider', () => { await waitForSessionConfig(provider, session!.sessionId, c => c?.values.autoApprove === 'default'); + // Full schema + values are retained; the JSONC settings editor relies + // on this to preserve non-mutable values through replace dispatches. const seeded = provider.getSessionConfig(session!.sessionId); assert.deepStrictEqual({ - properties: Object.keys(seeded?.schema.properties ?? {}), + properties: Object.keys(seeded?.schema.properties ?? {}).sort(), values: seeded?.values, }, { - properties: ['autoApprove'], - values: { autoApprove: 'default' }, + properties: ['autoApprove', 'isolation'], + values: { autoApprove: 'default', isolation: 'worktree' }, }); })); diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index 576ebcd86990e..884c9db5f6f87 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -311,8 +311,8 @@ font-weight: 500; color: var(--vscode-descriptionForeground); text-transform: uppercase; - /* align with session item margin */ - padding: 0 10px; + /* align left with session item margin; keep extra right padding for section content spacing */ + padding: 0 16px 0 10px; .session-section-label { flex: 1 1 auto; diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css index ff99fdda08d3d..0dc25732d0fe9 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css @@ -29,12 +29,13 @@ .command-center .agent-sessions-titlebar-container { display: flex; - width: 100%; + flex: 1; flex-direction: row; flex-wrap: nowrap; align-items: center; justify-content: flex-start; - padding-left: 16px; + min-width: 0; + margin-left: 16px; height: 22px; border-radius: 4px; -webkit-app-region: no-drag; @@ -45,7 +46,7 @@ } .agent-sessions-workbench:not(.nosidebar) .command-center .agent-sessions-titlebar-container { - padding-left: 0; + margin-left: 0; } /* Session pill - clickable area for session picker */ diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 8c2f95af2729f..84362122209ff 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -28,6 +28,11 @@ const agentSessionsViewContainer: ViewContainer = Registry.as([ + [Parts.AUXILIARYBAR_PART, true], + [Parts.EDITOR_PART, false], + ]); + + private readonly _onDidChangePartVisibility = new Emitter(); + override readonly onDidChangePartVisibility = this._onDidChangePartVisibility.event; + + override isVisible(part: Parts, _targetWindow?: Window): boolean { + return this._visibleParts.get(part) ?? false; + } + + setVisible(part: Parts, visible: boolean): void { + this._visibleParts.set(part, visible); + this._onDidChangePartVisibility.fire({ partId: part, visible }); + } + + dispose(): void { + this._onDidChangePartVisibility.dispose(); + } +} + +suite('Sessions - Auxiliary Bar Part', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let instantiationService: TestInstantiationService; + let layoutService: MutableTestLayoutService; + let auxiliaryBarPart: AuxiliaryBarPart; + + setup(() => { + layoutService = disposables.add(new MutableTestLayoutService()); + instantiationService = workbenchInstantiationService({}, disposables); + instantiationService.stub(IWorkbenchLayoutService, layoutService as IWorkbenchLayoutService); + const viewDescriptorService = disposables.add(instantiationService.createInstance(ViewDescriptorService)); + instantiationService.stub(IViewDescriptorService, viewDescriptorService); + auxiliaryBarPart = disposables.add(instantiationService.createInstance(AuxiliaryBarPart)); + }); + + test('keeps the default minimum width and disables sash snap when the editor part is visible', () => { + layoutService.setVisible(Parts.EDITOR_PART, true); + + assert.strictEqual(auxiliaryBarPart.minimumWidth, 270); + assert.strictEqual(auxiliaryBarPart.snap, false); + }); + + test('restores sash snap when the editor part is hidden', () => { + layoutService.setVisible(Parts.EDITOR_PART, true); + assert.strictEqual(auxiliaryBarPart.snap, false); + + layoutService.setVisible(Parts.EDITOR_PART, false); + assert.strictEqual(auxiliaryBarPart.snap, true); + }); +}); diff --git a/src/vs/sessions/test/common/agentHostSessionsProvider.test.ts b/src/vs/sessions/test/common/agentHostSessionsProvider.test.ts new file mode 100644 index 0000000000000..ae8838e777b80 --- /dev/null +++ b/src/vs/sessions/test/common/agentHostSessionsProvider.test.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { buildMutableConfigSchema } from '../../common/agentHostSessionsProvider.js'; + +suite('buildMutableConfigSchema', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('derives per-value schema entries and special-cases autoApprove', () => { + const actual = buildMutableConfigSchema({ + autoApprove: 'default', + mode: 'worktree', + timeout: 5000, + enabled: true, + tags: ['a', 'b'], + permissions: { allow: ['Tool'], deny: [] }, + nothing: undefined, + missing: null, + }); + + assert.deepStrictEqual(actual, { + autoApprove: { + type: 'string', + title: 'autoApprove', + sessionMutable: true, + enum: ['default', 'autoApprove', 'autopilot'], + }, + mode: { + type: 'string', + title: 'mode', + sessionMutable: true, + enum: ['worktree'], + }, + timeout: { type: 'number', title: 'timeout', sessionMutable: true }, + enabled: { type: 'boolean', title: 'enabled', sessionMutable: true }, + tags: { type: 'array', title: 'tags', sessionMutable: true }, + permissions: { type: 'object', title: 'permissions', sessionMutable: true }, + // `undefined` and `null` are omitted — they aren't representable in + // the config schema. + }); + }); +}); diff --git a/src/vs/workbench/browser/actions/layoutActions.ts b/src/vs/workbench/browser/actions/layoutActions.ts index 86f783cefa4c0..5a76e771d9948 100644 --- a/src/vs/workbench/browser/actions/layoutActions.ts +++ b/src/vs/workbench/browser/actions/layoutActions.ts @@ -276,6 +276,7 @@ MenuRegistry.appendMenuItem(MenuId.MenubarViewMenu, { group: '2_appearance', title: localize({ key: 'miAppearance', comment: ['&& denotes a mnemonic'] }, "&&Appearance"), submenu: MenuId.MenubarAppearanceMenu, + when: IsSessionsWindowContext.negate(), order: 1 }); diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index c213b29de93ce..a8808a5c4e277 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -2862,6 +2862,7 @@ class LayoutStateModel extends Disposable { [StorageScope.WORKSPACE]: boolean; [StorageScope.PROFILE]: boolean; [StorageScope.APPLICATION]: boolean; + [StorageScope.APPLICATION_SHARED]: boolean; }; constructor( @@ -2875,7 +2876,8 @@ class LayoutStateModel extends Disposable { this.isNew = { [StorageScope.WORKSPACE]: this.storageService.isNew(StorageScope.WORKSPACE), [StorageScope.PROFILE]: this.storageService.isNew(StorageScope.PROFILE), - [StorageScope.APPLICATION]: this.storageService.isNew(StorageScope.APPLICATION) + [StorageScope.APPLICATION]: this.storageService.isNew(StorageScope.APPLICATION), + [StorageScope.APPLICATION_SHARED]: this.storageService.isNew(StorageScope.APPLICATION_SHARED) }; this._register(this.configurationService.onDidChangeConfiguration(configurationChange => this.updateStateFromLegacySettings(configurationChange))); diff --git a/src/vs/workbench/browser/parts/titlebar/menubar.contribution.ts b/src/vs/workbench/browser/parts/titlebar/menubar.contribution.ts new file mode 100644 index 0000000000000..4794c30c9ec18 --- /dev/null +++ b/src/vs/workbench/browser/parts/titlebar/menubar.contribution.ts @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; +import { MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { IsMacNativeContext } from '../../../../platform/contextkey/common/contextkeys.js'; + +MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { + submenu: MenuId.MenubarFileMenu, + title: { + value: 'File', + original: 'File', + mnemonicTitle: localize({ key: 'mFile', comment: ['&& denotes a mnemonic'] }, "&&File"), + }, + order: 1 +}); + +MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { + submenu: MenuId.MenubarEditMenu, + title: { + value: 'Edit', + original: 'Edit', + mnemonicTitle: localize({ key: 'mEdit', comment: ['&& denotes a mnemonic'] }, "&&Edit") + }, + order: 2 +}); + +MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { + submenu: MenuId.MenubarSelectionMenu, + title: { + value: 'Selection', + original: 'Selection', + mnemonicTitle: localize({ key: 'mSelection', comment: ['&& denotes a mnemonic'] }, "&&Selection") + }, + order: 3 +}); + +MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { + submenu: MenuId.MenubarViewMenu, + title: { + value: 'View', + original: 'View', + mnemonicTitle: localize({ key: 'mView', comment: ['&& denotes a mnemonic'] }, "&&View") + }, + order: 4 +}); + +MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { + submenu: MenuId.MenubarGoMenu, + title: { + value: 'Go', + original: 'Go', + mnemonicTitle: localize({ key: 'mGoto', comment: ['&& denotes a mnemonic'] }, "&&Go") + }, + order: 5 +}); + +MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { + submenu: MenuId.MenubarTerminalMenu, + title: { + value: 'Terminal', + original: 'Terminal', + mnemonicTitle: localize({ key: 'mTerminal', comment: ['&& denotes a mnemonic'] }, "&&Terminal") + }, + order: 7 +}); + +MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { + submenu: MenuId.MenubarHelpMenu, + title: { + value: 'Help', + original: 'Help', + mnemonicTitle: localize({ key: 'mHelp', comment: ['&& denotes a mnemonic'] }, "&&Help") + }, + order: 8 +}); + +MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { + submenu: MenuId.MenubarPreferencesMenu, + title: { + value: 'Preferences', + original: 'Preferences', + mnemonicTitle: localize({ key: 'mPreferences', comment: ['&& denotes a mnemonic'] }, "&&Preferences") + }, + when: IsMacNativeContext, + order: 9 +}); diff --git a/src/vs/workbench/browser/parts/titlebar/menubarControl.ts b/src/vs/workbench/browser/parts/titlebar/menubarControl.ts index 2c1d45ea6aa83..c472fe1e2f091 100644 --- a/src/vs/workbench/browser/parts/titlebar/menubarControl.ts +++ b/src/vs/workbench/browser/parts/titlebar/menubarControl.ts @@ -5,7 +5,7 @@ import './media/menubarControl.css'; import { localize, localize2 } from '../../../../nls.js'; -import { IMenuService, MenuId, IMenu, SubmenuItemAction, registerAction2, Action2, MenuItemAction, MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { IMenuService, MenuId, IMenu, SubmenuItemAction, registerAction2, Action2, MenuItemAction } from '../../../../platform/actions/common/actions.js'; import { MenuBarVisibility, IWindowOpenable, getMenuBarVisibility, MenuSettings, hasNativeMenu } from '../../../../platform/window/common/window.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IAction, Action, SubmenuAction, Separator, IActionRunner, ActionRunner, WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification, toAction } from '../../../../base/common/actions.js'; @@ -33,7 +33,7 @@ import { IHostService } from '../../../services/host/browser/host.js'; import { BrowserFeatures } from '../../../../base/browser/canIUse.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { IsMacNativeContext, IsWebContext } from '../../../../platform/contextkey/common/contextkeys.js'; +import { IsWebContext } from '../../../../platform/contextkey/common/contextkeys.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { OpenRecentAction } from '../../actions/windowActions.js'; @@ -45,87 +45,6 @@ import { ActivityBarPosition } from '../../../services/layout/browser/layoutServ export type IOpenRecentAction = IAction & { uri: URI; remoteAuthority?: string }; -MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { - submenu: MenuId.MenubarFileMenu, - title: { - value: 'File', - original: 'File', - mnemonicTitle: localize({ key: 'mFile', comment: ['&& denotes a mnemonic'] }, "&&File"), - }, - order: 1 -}); - -MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { - submenu: MenuId.MenubarEditMenu, - title: { - value: 'Edit', - original: 'Edit', - mnemonicTitle: localize({ key: 'mEdit', comment: ['&& denotes a mnemonic'] }, "&&Edit") - }, - order: 2 -}); - -MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { - submenu: MenuId.MenubarSelectionMenu, - title: { - value: 'Selection', - original: 'Selection', - mnemonicTitle: localize({ key: 'mSelection', comment: ['&& denotes a mnemonic'] }, "&&Selection") - }, - order: 3 -}); - -MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { - submenu: MenuId.MenubarViewMenu, - title: { - value: 'View', - original: 'View', - mnemonicTitle: localize({ key: 'mView', comment: ['&& denotes a mnemonic'] }, "&&View") - }, - order: 4 -}); - -MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { - submenu: MenuId.MenubarGoMenu, - title: { - value: 'Go', - original: 'Go', - mnemonicTitle: localize({ key: 'mGoto', comment: ['&& denotes a mnemonic'] }, "&&Go") - }, - order: 5 -}); - -MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { - submenu: MenuId.MenubarTerminalMenu, - title: { - value: 'Terminal', - original: 'Terminal', - mnemonicTitle: localize({ key: 'mTerminal', comment: ['&& denotes a mnemonic'] }, "&&Terminal") - }, - order: 7 -}); - -MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { - submenu: MenuId.MenubarHelpMenu, - title: { - value: 'Help', - original: 'Help', - mnemonicTitle: localize({ key: 'mHelp', comment: ['&& denotes a mnemonic'] }, "&&Help") - }, - order: 8 -}); - -MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { - submenu: MenuId.MenubarPreferencesMenu, - title: { - value: 'Preferences', - original: 'Preferences', - mnemonicTitle: localize({ key: 'mPreferences', comment: ['&& denotes a mnemonic'] }, "Preferences") - }, - when: IsMacNativeContext, - order: 9 -}); - export abstract class MenubarControl extends Disposable { protected keys = [ diff --git a/src/vs/workbench/common/memento.ts b/src/vs/workbench/common/memento.ts index b251e739f6c67..2e5cdcc2255dc 100644 --- a/src/vs/workbench/common/memento.ts +++ b/src/vs/workbench/common/memento.ts @@ -12,6 +12,7 @@ import { Event } from '../../base/common/event.js'; export class Memento { private static readonly applicationMementos = new Map>(); + private static readonly applicationSharedMementos = new Map>(); private static readonly profileMementos = new Map>(); private static readonly workspaceMementos = new Map>(); @@ -54,6 +55,16 @@ export class Memento { return applicationMemento.getMemento(); } + + case StorageScope.APPLICATION_SHARED: { + let applicationSharedMemento = Memento.applicationSharedMementos.get(this.id); + if (!applicationSharedMemento) { + applicationSharedMemento = new ScopedMemento(this.id, scope, target, this.storageService); + Memento.applicationSharedMementos.set(this.id, applicationSharedMemento); + } + + return applicationSharedMemento.getMemento(); + } } } @@ -65,11 +76,15 @@ export class Memento { Memento.workspaceMementos.get(this.id)?.save(); Memento.profileMementos.get(this.id)?.save(); Memento.applicationMementos.get(this.id)?.save(); + Memento.applicationSharedMementos.get(this.id)?.save(); } reloadMemento(scope: StorageScope): void { let memento: ScopedMemento | undefined; switch (scope) { + case StorageScope.APPLICATION_SHARED: + memento = Memento.applicationSharedMementos.get(this.id); + break; case StorageScope.APPLICATION: memento = Memento.applicationMementos.get(this.id); break; @@ -95,6 +110,9 @@ export class Memento { case StorageScope.APPLICATION: Memento.applicationMementos.clear(); break; + case StorageScope.APPLICATION_SHARED: + Memento.applicationSharedMementos.clear(); + break; } } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index 10386adfb330f..4d0acedf931d3 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -10,8 +10,8 @@ import { isEqualOrParent } from '../../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { URI } from '../../../../../../base/common/uri.js'; import { AgentHostEnabledSettingId, IAgentHostService, type AgentProvider } from '../../../../../../platform/agentHost/common/agentService.js'; -import { type IProtectedResourceMetadata, type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { type IAgentInfo, type ICustomizationRef, type IRootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { type ProtectedResourceMetadata, type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { type AgentInfo, type CustomizationRef, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -101,7 +101,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr } } - private _handleRootStateChange(rootState: IRootState): void { + private _handleRootStateChange(rootState: RootState): void { const incoming = new Set(rootState.agents.map(a => a.provider)); // Remove agents that are no longer present @@ -128,7 +128,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr } } - private _registerAgent(agent: IAgentInfo): void { + private _registerAgent(agent: AgentInfo): void { const store = new DisposableStore(); this._agentRegistrations.set(agent.provider, store); const sessionType = `agent-host-${agent.provider}`; @@ -169,7 +169,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr syncProvider, })); - const customizations = observableValue('agentCustomizations', []); + const customizations = observableValue('agentCustomizations', []); const updateCustomizations = async () => { const refs = await this._resolveCustomizations(syncProvider, bundler); customizations.set(refs, undefined); @@ -224,14 +224,14 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr private async _resolveCustomizations( syncProvider: AgentCustomizationSyncProvider, bundler: SyncedCustomizationBundler, - ): Promise { + ): Promise { const entries = syncProvider.getSelectedEntries(); if (entries.length === 0) { return []; } const plugins = this._agentPluginService.plugins.get(); - const refs: ICustomizationRef[] = []; + const refs: CustomizationRef[] = []; const individualFiles: { uri: URI; type: PromptsType }[] = []; for (const entry of entries) { @@ -256,7 +256,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr return refs; } - private _getRootAgents(): readonly IAgentInfo[] { + private _getRootAgents(): readonly AgentInfo[] { const rootState = this._agentHostService.rootState.value; return (rootState && !(rootState instanceof Error)) ? rootState.agents : []; } @@ -265,7 +265,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr * Authenticate using protectedResources from agent info in root state. * Resolves tokens via the standard VS Code authentication service. */ - private async _authenticateWithServer(agents: readonly IAgentInfo[]): Promise { + private async _authenticateWithServer(agents: readonly AgentInfo[]): Promise { this._agentHostService.setAuthenticationPending(true); try { for (const agent of agents) { @@ -302,7 +302,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr * creates a session (which triggers the login UI), and pushes the token * to the server. Returns true if authentication succeeded. */ - private async _resolveAuthenticationInteractively(protectedResources: IProtectedResourceMetadata[]): Promise { + private async _resolveAuthenticationInteractively(protectedResources: ProtectedResourceMetadata[]): Promise { try { for (const resource of protectedResources) { const resourceUri = URI.parse(resource.resource); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostEditingSession.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostEditingSession.ts index 8df04dc28b3d6..b1cfc5460473d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostEditingSession.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostEditingSession.ts @@ -19,7 +19,7 @@ import { IEditorWorkerService } from '../../../../../../editor/common/services/e import { ITextModelService } from '../../../../../../editor/common/services/resolverService.js'; import { localize } from '../../../../../../nls.js'; import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; -import { FileEditKind, ToolCallStatus, type IToolCallState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { FileEditKind, ToolCallStatus, type ToolCallState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { EditorActivation } from '../../../../../../platform/editor/common/editor.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -183,7 +183,7 @@ export class AgentHostEditingSession extends Disposable implements IChatEditingS } } - addToolCallEdits(requestId: string, tc: IToolCallState): IChatProgress[] { + addToolCallEdits(requestId: string, tc: ToolCallState): IChatProgress[] { if (tc.status !== ToolCallStatus.Completed) { return []; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts index d201fafa2c8ce..ba11c82ea051c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts @@ -6,20 +6,20 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; -import { IConfigSchema, ISessionModelInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ConfigSchema, SessionModelInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { nullExtensionDescription } from '../../../../../services/extensions/common/extensions.js'; import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelConfigurationSchema } from '../../../common/languageModels.js'; /** * Exposes models available from the agent host process as selectable * language models in the chat model picker. Models are provided from - * root state (via {@link IAgentInfo.models}) rather than via RPC. + * root state (via {@link AgentInfo.models}) rather than via RPC. */ export class AgentHostLanguageModelProvider extends Disposable implements ILanguageModelChatProvider { private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange = this._onDidChange.event; - private _models: readonly ISessionModelInfo[] = []; + private _models: readonly SessionModelInfo[] = []; constructor( private readonly _sessionType: string, @@ -31,7 +31,7 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu /** * Called by {@link AgentHostContribution} when models change in root state. */ - updateModels(models: readonly ISessionModelInfo[]): void { + updateModels(models: readonly SessionModelInfo[]): void { this._models = models; this._onDidChange.fire(); } @@ -64,7 +64,7 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu })); } - private _toLanguageModelConfigurationSchema(schema: IConfigSchema | undefined): ILanguageModelConfigurationSchema | undefined { + private _toLanguageModelConfigurationSchema(schema: ConfigSchema | undefined): ILanguageModelConfigurationSchema | undefined { if (!schema) { return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index a79f531f9ef47..eaf8529f0a4d4 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -17,11 +17,11 @@ import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; import { AgentHostSessionConfigBranchNameHintKey, AgentProvider, AgentSession, IAgentAttachment, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; -import { ISessionTruncatedAction } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; -import { ICustomizationRef, TerminalClaimKind, ToolResultContentType, type IProtectedResourceMetadata, type IToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { ActionType, ISessionTurnStartedAction, type ISessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { SessionTruncatedAction } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; +import { ConfirmationOptionKind, CustomizationRef, TerminalClaimKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ActionType, SessionTurnStartedAction, type SessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { AttachmentType, buildSubagentSessionUri, getToolFileEdits, getToolSubagentContent, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ICompletedToolCall, type IMessageAttachment, type IModelSelection, type IResponsePart, type IRootState, type ISessionInputAnswer, type ISessionInputRequest, type ISessionState, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { AttachmentType, buildSubagentSessionUri, getToolFileEdits, getToolSubagentContent, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ICompletedToolCall, type MessageAttachment, type ModelSelection, type ResponsePart, type RootState, type SessionInputAnswer, type SessionInputRequest, type SessionState, type ToolCallState, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -67,7 +67,7 @@ interface ITurnProcessingContext { readonly progress: (parts: IChatProgress[]) => void; readonly cancellationToken: CancellationToken; /** Called when a completed tool produces file edits. */ - readonly onFileEdits?: (tc: IToolCallState, fileEdits: IToolCallFileEdit[]) => void; + readonly onFileEdits?: (tc: ToolCallState, fileEdits: IToolCallFileEdit[]) => void; } /** @@ -119,11 +119,11 @@ function confirmedReasonToProtocol(reason: ConfirmedReason | undefined): ToolCal /** * Converts carousel answers (IChatQuestionAnswers) to protocol - * ISessionInputAnswer records, handling text, single-select, + * SessionInputAnswer records, handling text, single-select, * and multi-select answer shapes. */ -export function convertCarouselAnswers(raw: IChatQuestionAnswers): Record { - const answers: Record = {}; +export function convertCarouselAnswers(raw: IChatQuestionAnswers): Record { + const answers: Record = {}; for (const [qId, answer] of Object.entries(raw)) { if (typeof answer === 'string') { answers[qId] = { @@ -289,13 +289,13 @@ export interface IAgentHostSessionHandlerConfig { * @param protectedResources The protected resources from the agent's root * state that require authentication. */ - readonly resolveAuthentication?: (protectedResources: IProtectedResourceMetadata[]) => Promise; + readonly resolveAuthentication?: (protectedResources: ProtectedResourceMetadata[]) => Promise; /** * Observable set of agent-level customizations to include in the active * client set. When the value changes, active sessions are updated. */ - readonly customizations?: IObservable; + readonly customizations?: IObservable; } export function getAgentHostBranchNameHint(message: string): string | undefined { @@ -321,13 +321,13 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC /** Per-session subscription watching for server-initiated turns. */ private readonly _serverTurnWatchers = this._register(new DisposableResourceMap()); /** Historical turns with file edits, pending hydration into the editing session. */ - private readonly _pendingHistoryTurns = new ResourceMap(); + private readonly _pendingHistoryTurns = new ResourceMap(); /** Turn IDs dispatched by this client, used to distinguish server-originated turns. */ private readonly _clientDispatchedTurnIds = new Set(); private readonly _config: IAgentHostSessionHandlerConfig; /** Active session subscriptions, keyed by backend session URI string. */ - private readonly _sessionSubscriptions = new Map>>(); + private readonly _sessionSubscriptions = new Map>>(); /** Observable of client-provided tools filtered by the allowlist and `when` clauses. */ private readonly _clientToolsObs: IObservable; @@ -731,7 +731,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } } - private _dispatchAction(action: ISessionAction): void { + private _dispatchAction(action: SessionAction): void { this._config.connection.dispatch(action); } @@ -740,7 +740,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * role for this session and publish the current customizations and * client-provided tools. */ - private _dispatchActiveClient(backendSession: URI, customizations: ICustomizationRef[]): void { + private _dispatchActiveClient(backendSession: URI, customizations: CustomizationRef[]): void { this._dispatchAction({ type: ActionType.SessionActiveClientChanged, session: backendSession.toString(), @@ -885,7 +885,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC cancellationToken: CancellationToken.None, }; - const processState = (sessionState: ISessionState) => { + const processState = (sessionState: SessionState) => { if (finished) { return; } @@ -931,7 +931,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._clientDispatchedTurnIds.add(turnId); const cleanUpTurnId = () => this._clientDispatchedTurnIds.delete(turnId); const attachments = this._convertVariablesToAttachments(request); - const messageAttachments: IMessageAttachment[] = attachments.map(a => ({ + const messageAttachments: MessageAttachment[] = attachments.map(a => ({ type: a.type, path: a.path, displayName: a.displayName, @@ -962,7 +962,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const previousRequestIndex = chatModel.getRequests().findIndex(i => i.id === request.requestId) - 1; const previousRequest = previousRequestIndex >= 0 ? chatModel.getRequests()[previousRequestIndex] : undefined; if (!previousRequest && protocolState.turns.length > 0) { - const truncateAction: ISessionTruncatedAction = { + const truncateAction: SessionTruncatedAction = { type: ActionType.SessionTruncated, session: session.toString(), }; @@ -970,7 +970,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } else { const seenAtIndex = protocolState.turns.findIndex(t => t.id === previousRequest!.id); if (seenAtIndex !== -1 && seenAtIndex < protocolState.turns.length - 1) { - const truncateAction: ISessionTruncatedAction = { + const truncateAction: SessionTruncatedAction = { type: ActionType.SessionTruncated, session: session.toString(), turnId: previousRequest!.id, @@ -982,7 +982,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // Dispatch session/turnStarted — the server will call sendMessage on // the provider as a side effect. - const turnAction: ISessionTurnStartedAction = { + const turnAction: SessionTurnStartedAction = { type: ActionType.SessionTurnStarted, session: session.toString(), turnId, @@ -1099,10 +1099,22 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC session: URI, turnId: string, cancellationToken: CancellationToken, + protocolOptions?: ConfirmationOption[], ): void { IChatToolInvocation.awaitConfirmation(invocation, cancellationToken).then(reason => { - const approved = reason.type !== ToolConfirmKind.Denied && reason.type !== ToolConfirmKind.Skipped; - this._logService.info(`[AgentHost] Tool confirmation: toolCallId=${toolCallId}, approved=${approved}`); + // When the user picked a custom button, resolve the matching + // protocol option so we can forward `selectedOptionId` and + // derive approve/deny from the option's kind. + let selectedOption: ConfirmationOption | undefined; + if (reason.type === ToolConfirmKind.UserAction && reason.selectedButton && protocolOptions) { + selectedOption = protocolOptions.find(o => o.id === reason.selectedButton); + } + + const approved = selectedOption + ? selectedOption.kind === ConfirmationOptionKind.Approve + : reason.type !== ToolConfirmKind.Denied && reason.type !== ToolConfirmKind.Skipped; + + this._logService.info(`[AgentHost] Tool confirmation: toolCallId=${toolCallId}, approved=${approved}, selectedOptionId=${selectedOption?.id}`); if (approved) { this._config.connection.dispatch({ type: ActionType.SessionToolCallConfirmed, @@ -1111,6 +1123,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC toolCallId, approved: true, confirmed: ToolCallConfirmationReason.UserAction, + ...(selectedOption ? { selectedOptionId: selectedOption.id } : {}), }); } else { this._config.connection.dispatch({ @@ -1120,6 +1133,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC toolCallId, approved: false, reason: ToolCallCancellationReason.Denied, + ...(selectedOption ? { selectedOptionId: selectedOption.id } : {}), }); } }).catch(err => { @@ -1141,7 +1155,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC */ private _updateToolCallState( existing: ChatToolInvocation, - tc: IToolCallState, + tc: ToolCallState, ctx: ITurnProcessingContext, ): { invocation: ChatToolInvocation; fileEdits: IToolCallFileEdit[] } { const toolCallId = tc.toolCallId; @@ -1155,7 +1169,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const confirmInvocation = toolCallStateToInvocation(tc, undefined, ctx.backendSession, this._config.connectionAuthority); ctx.activeToolInvocations.set(toolCallId, confirmInvocation); ctx.progress([confirmInvocation]); - this._awaitToolConfirmation(confirmInvocation, toolCallId, ctx.backendSession, ctx.turnId, ctx.cancellationToken); + this._awaitToolConfirmation(confirmInvocation, toolCallId, ctx.backendSession, ctx.turnId, ctx.cancellationToken, tc.options); existing = confirmInvocation; } } else if (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.PendingResultConfirmation) { @@ -1197,7 +1211,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * The actual {@link ILanguageModelToolsService.invokeTool} call is * deferred until {@link _tryInvokeClientTool} sees the tool parameters. */ - private _beginClientToolInvocation(tc: IToolCallState, ctx: ITurnProcessingContext): void { + private _beginClientToolInvocation(tc: ToolCallState, ctx: ITurnProcessingContext): void { const toolCallId = tc.toolCallId; const toolData = this._toolsService.getToolByName(tc.toolName); @@ -1319,7 +1333,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * streaming invocation created in {@link _beginClientToolInvocation}. * Settlement is handled by {@link _handleClientToolSettled}. */ - private _tryInvokeClientTool(tc: IToolCallState, ctx: ITurnProcessingContext): void { + private _tryInvokeClientTool(tc: ToolCallState, ctx: ITurnProcessingContext): void { const entry = this._clientToolCalls.get(tc.toolCallId); if (!entry || entry.invoked || entry.cts.token.isCancellationRequested) { return; @@ -1488,7 +1502,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC */ private _reviveTerminalIfNeeded( invocation: ChatToolInvocation, - tc: IToolCallState, + tc: ToolCallState, backendSession: URI, ): void { // content is only present on Running/Completed/PendingResultConfirmation. @@ -1541,7 +1555,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * `progress`. */ private _processSessionState( - sessionState: ISessionState, + sessionState: SessionState, ctx: ITurnProcessingContext, ): boolean { const activeTurn = sessionState.activeTurn; @@ -1595,7 +1609,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC ctx.progress([existing]); if (tc.status === ToolCallStatus.PendingConfirmation) { - this._awaitToolConfirmation(existing, tc.toolCallId, ctx.backendSession, ctx.turnId, ctx.cancellationToken); + this._awaitToolConfirmation(existing, tc.toolCallId, ctx.backendSession, ctx.turnId, ctx.cancellationToken, tc.options); } else { // First snapshot may already be Running/Completed/ // Cancelled (due to throttling). Process immediately @@ -1647,7 +1661,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC */ private _syncInputRequests( active: Map, - inputRequests: readonly ISessionInputRequest[] | undefined, + inputRequests: readonly SessionInputRequest[] | undefined, session: URI, token: CancellationToken, progress: (items: IChatProgress[]) => void, @@ -1673,7 +1687,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * the `SessionInputCompleted` action when the user answers or cancels. */ private _handleInputRequest( - inputReq: ISessionInputRequest, + inputReq: SessionInputRequest, session: URI, cancellationToken: CancellationToken, progress: (items: IChatProgress[]) => void, @@ -1782,7 +1796,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * tool call IDs. */ private _observeSubagentToolCalls( - sessionState: ISessionState, + sessionState: SessionState, turnId: string, activeToolInvocations: Map, observedSubagentToolIds: Set, @@ -1909,7 +1923,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC disposables.add(toDisposable(() => childCts.dispose(true))); // Helper to process response parts from a child turn - const processChildParts = (responseParts: readonly IResponsePart[], turnId: string) => { + const processChildParts = (responseParts: readonly ResponsePart[], turnId: string) => { for (const rp of responseParts) { if (rp.kind === ResponsePartKind.ToolCall) { const tc = rp.toolCall; @@ -1921,7 +1935,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC emitProgress([existing]); if (tc.status === ToolCallStatus.PendingConfirmation) { - this._awaitToolConfirmation(existing, tc.toolCallId, URI.parse(childSessionUri), turnId, childCts.token); + this._awaitToolConfirmation(existing, tc.toolCallId, URI.parse(childSessionUri), turnId, childCts.token, tc.options); } } else if (tc.status === ToolCallStatus.PendingConfirmation) { const existingState = existing.state.get(); @@ -1930,7 +1944,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const confirmInvocation = toolCallStateToInvocation(tc, parentToolCallId, URI.parse(childSessionUri), this._config.connectionAuthority); activeChildToolInvocations.set(tc.toolCallId, confirmInvocation); emitProgress([confirmInvocation]); - this._awaitToolConfirmation(confirmInvocation, tc.toolCallId, URI.parse(childSessionUri), turnId, childCts.token); + this._awaitToolConfirmation(confirmInvocation, tc.toolCallId, URI.parse(childSessionUri), turnId, childCts.token, tc.options); } } else if (tc.status === ToolCallStatus.Running) { updateRunningToolSpecificData(existing, tc, this._config.connectionAuthority); @@ -2037,7 +2051,14 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC reconnectDisposables.add(toDisposable(() => cts.dispose(true))); for (const [toolCallId, invocation] of activeToolInvocations) { if (!IChatToolInvocation.isComplete(invocation)) { - this._awaitToolConfirmation(invocation, toolCallId, backendSession, turnId, cts.token); + // Look up the tool call state to forward protocol options on reconnection + const tcState = currentState?.activeTurn?.responseParts.find( + rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === toolCallId + ); + const tcOptions = tcState?.kind === ResponsePartKind.ToolCall && tcState.toolCall.status === ToolCallStatus.PendingConfirmation + ? tcState.toolCall.options + : undefined; + this._awaitToolConfirmation(invocation, toolCallId, backendSession, turnId, cts.token, tcOptions); } if (invocation.toolSpecificData?.kind === 'subagent' && !observedSubagentToolIds.has(toolCallId)) { observedSubagentToolIds.add(toolCallId); @@ -2062,7 +2083,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC progress: parts => chatSession.appendProgress(parts), cancellationToken: cts.token, }; - const processStateChange = (sessionState: ISessionState) => { + const processStateChange = (sessionState: SessionState) => { const isActive = this._processSessionState(sessionState, ctx); this._syncInputRequests(activeInputRequests, sessionState.inputRequests, backendSession, cts.token, appendProgress); @@ -2138,7 +2159,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC private _hydrateFileEdits( sessionResource: URI, requestId: string, - tc: IToolCallState, + tc: ToolCallState, ): IChatProgress[] { const editingSession = this._ensureEditingSession(sessionResource); if (editingSession) { @@ -2237,7 +2258,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } /** Creates a new backend session and subscribes to its state. */ - private async _createAndSubscribe(sessionResource: URI, model: IModelSelection | undefined, fork?: { session: URI; turnIndex: number; turnId: string }, sessionConfig?: Record, branchNameHint?: string): Promise { + private async _createAndSubscribe(sessionResource: URI, model: ModelSelection | undefined, fork?: { session: URI; turnIndex: number; turnId: string }, sessionConfig?: Record, branchNameHint?: string): Promise { const config = branchNameHint ? { ...sessionConfig, [AgentHostSessionConfigBranchNameHintKey]: branchNameHint } : sessionConfig; const workingDirectory = this._config.resolveWorkingDirectory?.(sessionResource) ?? this._workingDirectoryResolver.resolve(sessionResource) @@ -2348,7 +2369,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return false; } - private _createModelSelection(languageModelIdentifier: string | undefined, modelConfiguration: Record | undefined): IModelSelection | undefined { + private _createModelSelection(languageModelIdentifier: string | undefined, modelConfiguration: Record | undefined): ModelSelection | undefined { const rawModelId = this._extractRawModelId(languageModelIdentifier); if (!rawModelId) { return undefined; @@ -2364,7 +2385,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return Object.keys(config).length > 0 ? { id: rawModelId, config } : { id: rawModelId }; } - private _modelSelectionsEqual(a: IModelSelection | undefined, b: IModelSelection | undefined): boolean { + private _modelSelectionsEqual(a: ModelSelection | undefined, b: ModelSelection | undefined): boolean { if (a?.id !== b?.id) { return false; } @@ -2433,7 +2454,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * Get or create a session subscription. The first call for a given URI * triggers a server subscribe; subsequent calls increment the refcount. */ - private _ensureSessionSubscription(sessionUri: string): IAgentSubscription { + private _ensureSessionSubscription(sessionUri: string): IAgentSubscription { let ref = this._sessionSubscriptions.get(sessionUri); if (!ref) { ref = this._config.connection.getSubscription(StateComponents.Session, URI.parse(sessionUri)); @@ -2457,7 +2478,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC /** * Read the current optimistic session state for a backend session URI. */ - private _getSessionState(sessionUri: string): ISessionState | undefined { + private _getSessionState(sessionUri: string): SessionState | undefined { const ref = this._sessionSubscriptions.get(sessionUri); if (!ref) { return undefined; @@ -2469,7 +2490,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC /** * Read the current root state. */ - private _getRootState(): IRootState | undefined { + private _getRootState(): RootState | undefined { const value = this._config.connection.rootState.value; return (value && !(value instanceof Error)) ? value : undefined; } @@ -2496,22 +2517,22 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // ============================================================================= /** - * Converts an internal {@link IToolData} to a protocol {@link IToolDefinition}. + * Converts an internal {@link IToolData} to a protocol {@link ToolDefinition}. */ -export function toolDataToDefinition(tool: IToolData): IToolDefinition { +export function toolDataToDefinition(tool: IToolData): ToolDefinition { return { name: tool.toolReferenceName ?? tool.id, title: tool.displayName, description: tool.modelDescription, inputSchema: tool.inputSchema?.type === 'object' - ? tool.inputSchema as IToolDefinition['inputSchema'] + ? tool.inputSchema as ToolDefinition['inputSchema'] : undefined, }; } /** * Converts an internal {@link IToolResult} to a protocol - * {@link import('../../../../../../platform/agentHost/common/state/protocol/state.js').IToolCallResult}. + * {@link import('../../../../../../platform/agentHost/common/state/protocol/state.js').ToolCallResult}. */ export function toolResultToProtocol(result: IToolResult, toolName: string): { success: boolean; 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 55f59189cb300..e51c8cb0c4895 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts @@ -10,7 +10,7 @@ import { hasKey } from '../../../../../../base/common/types.js'; import { URI } from '../../../../../../base/common/uri.js'; import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { AgentSession, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; -import { ISessionFileDiff, SessionStatus, type ISessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ISessionFileDiff, SessionStatus, type SessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { ChatSessionStatus, IChatSessionFileChange2, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta } from '../../../common/chatSessionsService.js'; import { getAgentHostIcon } from '../agentSessions.js'; @@ -75,7 +75,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS private _items: IChatSessionItem[] = []; /** Cached full summaries per session so partial updates can be applied. */ - private readonly _cachedSummaries = new Map(); + private readonly _cachedSummaries = new Map(); constructor( private readonly _sessionType: string, @@ -170,7 +170,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS this._onDidChangeChatSessionItems.fire({ addedOrUpdated: this._items }); } - private _makeItemFromSummary(rawId: string, summary: ISessionSummary, diffs: readonly ISessionFileDiff[] | undefined): IChatSessionItem { + private _makeItemFromSummary(rawId: string, summary: SessionSummary, diffs: readonly ISessionFileDiff[] | undefined): IChatSessionItem { const workingDir = typeof summary.workingDirectory === 'string' ? URI.parse(summary.workingDirectory) : summary.workingDirectory; return this._makeItem(rawId, { title: summary.title, 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 f5533e8d92399..93a5537200ac9 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts @@ -7,12 +7,12 @@ import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, IReference, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { URI, UriComponents } from '../../../../../../base/common/uri.js'; import { Registry } from '../../../../../../platform/registry/common/platform.js'; -import { IAgentConnection, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, AgentHostIpcLoggingSettingId } from '../../../../../../platform/agentHost/common/agentService.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 IRootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; -import type { IActionEnvelope, INotification, ISessionAction, ITerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import type { ICreateTerminalParams, IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; -import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult } from '../../../../../../platform/agentHost/common/state/sessionProtocol.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 { 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'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -43,8 +43,8 @@ class LoggingAgentSubscription extends Disposable implements IAgentSubscripti private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; - readonly onWillApplyAction: Event; - readonly onDidApplyAction: Event; + readonly onWillApplyAction: Event; + readonly onDidApplyAction: Event; constructor( private readonly _label: string, @@ -109,9 +109,9 @@ export class LoggingAgentConnection extends Disposable implements IAgentConnecti private readonly _enabled: boolean; readonly clientId: string; - readonly onDidAction: Event; + readonly onDidAction: Event; readonly onDidNotification: Event; - private readonly _rootState: IAgentSubscription; + private readonly _rootState: IAgentSubscription; constructor( private readonly _inner: IAgentConnection, @@ -172,7 +172,7 @@ export class LoggingAgentConnection extends Disposable implements IAgentConnecti // ---- IAgentConnection method proxies with logging ----------------------- - async authenticate(params: IAuthenticateParams): Promise { + async authenticate(params: AuthenticateParams): Promise { return this._logCall('authenticate', params, () => this._inner.authenticate(params)); } @@ -184,11 +184,11 @@ export class LoggingAgentConnection extends Disposable implements IAgentConnecti return this._logCall('createSession', config, () => this._inner.createSession(config)); } - async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { + async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { return this._logCall('resolveSessionConfig', params, () => this._inner.resolveSessionConfig(params)); } - async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { + async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { return this._logCall('sessionConfigCompletions', params, () => this._inner.sessionConfigCompletions(params)); } @@ -196,7 +196,7 @@ export class LoggingAgentConnection extends Disposable implements IAgentConnecti return this._logCall('disposeSession', session, () => this._inner.disposeSession(session)); } - async createTerminal(params: ICreateTerminalParams): Promise { + async createTerminal(params: CreateTerminalParams): Promise { return this._logCall('createTerminal', params, () => this._inner.createTerminal(params)); } @@ -204,7 +204,7 @@ export class LoggingAgentConnection extends Disposable implements IAgentConnecti return this._logCall('disposeTerminal', terminal, () => this._inner.disposeTerminal(terminal)); } - get rootState(): IAgentSubscription { + get rootState(): IAgentSubscription { return this._rootState; } @@ -216,32 +216,32 @@ export class LoggingAgentConnection extends Disposable implements IAgentConnecti return this._inner.getSubscriptionUnmanaged(kind, resource); } - dispatch(action: ISessionAction | ITerminalAction): void { + dispatch(action: SessionAction | TerminalAction): void { this._log('>>', 'dispatch', action); this._inner.dispatch(action); } - async resourceList(uri: URI): Promise { + async resourceList(uri: URI): Promise { return this._logCall('resourceList', uri, () => this._inner.resourceList(uri)); } - async resourceRead(uri: URI): Promise { + async resourceRead(uri: URI): Promise { return this._logCall('resourceRead', uri, () => this._inner.resourceRead(uri)); } - async resourceWrite(params: IResourceWriteParams): Promise { + async resourceWrite(params: ResourceWriteParams): Promise { return this._logCall('resourceWrite', params, () => this._inner.resourceWrite(params)); } - async resourceCopy(params: IResourceCopyParams): Promise { + async resourceCopy(params: ResourceCopyParams): Promise { return this._logCall('resourceCopy', params, () => this._inner.resourceCopy(params)); } - async resourceDelete(params: IResourceDeleteParams): Promise { + async resourceDelete(params: ResourceDeleteParams): Promise { return this._logCall('resourceDelete', params, () => this._inner.resourceDelete(params)); } - async resourceMove(params: IResourceMoveParams): Promise { + async resourceMove(params: ResourceMoveParams): Promise { return this._logCall('resourceMove', params, () => this._inner.resourceMove(params)); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 4707e8d04867c..cf20764e0a2a3 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -6,10 +6,10 @@ import { IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { marked, type Token, type Tokens, type TokensList } from '../../../../../../base/common/marked/marked.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { ToolCallStatus, TurnState, ResponsePartKind, getToolFileEdits, getToolOutputText, getToolSubagentContent, type IActiveTurn, type ICompletedToolCall, type IToolCallState, type ITurn, FileEditKind, ToolResultContentType, type IToolResultContent } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ToolCallStatus, TurnState, ResponsePartKind, getToolFileEdits, getToolOutputText, getToolSubagentContent, type ActiveTurn, type ICompletedToolCall, type ToolCallState, type Turn, FileEditKind, ToolResultContentType, type ToolResultContent } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { getToolKind } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { AGENT_HOST_SCHEME, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; -import { StringOrMarkdown, type IFileEdit } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { StringOrMarkdown, type FileEdit } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { type IChatModifiedFilesConfirmationData, type IChatProgress, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { type IChatSessionHistoryItem } from '../../../common/chatSessionsService.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; @@ -74,7 +74,7 @@ export function isSubagentToolName(toolName: string): boolean { * the server reported `_meta.toolKind === 'subagent'` or because the tool * name is in the known fallback set (older snapshots without `_meta`). */ -export function isSubagentTool(tc: IToolCallState): boolean { +export function isSubagentTool(tc: ToolCallState): boolean { return getToolKind(tc) === 'subagent' || isSubagentToolName(tc.toolName); } @@ -82,7 +82,7 @@ export function isSubagentTool(tc: IToolCallState): boolean { * Finds a terminal content block in a tool call's content array. * Returns the terminal URI if found. */ -export function getTerminalContentUri(content: IToolResultContent[] | undefined): string | undefined { +export function getTerminalContentUri(content: ToolResultContent[] | undefined): string | undefined { if (!content) { return undefined; } @@ -97,7 +97,7 @@ export function getTerminalContentUri(content: IToolResultContent[] | undefined) /** * Converts completed turns from the protocol state into session history items. */ -export function turnsToHistory(backendSession: URI, turns: readonly ITurn[], participantId: string, connectionAuthority: string | undefined, modelId?: string): IChatSessionHistoryItem[] { +export function turnsToHistory(backendSession: URI, turns: readonly Turn[], participantId: string, connectionAuthority: string | undefined, modelId?: string): IChatSessionHistoryItem[] { const history: IChatSessionHistoryItem[] = []; for (const turn of turns) { // Request @@ -155,7 +155,7 @@ export function turnsToHistory(backendSession: URI, turns: readonly ITurn[], par * reasoning, completed tool calls) and live {@link ChatToolInvocation} * objects for running tool calls and pending confirmations. */ -export function activeTurnToProgress(sessionResource: URI, activeTurn: IActiveTurn, connectionAuthority: string | undefined): IChatProgress[] { +export function activeTurnToProgress(sessionResource: URI, activeTurn: ActiveTurn, connectionAuthority: string | undefined): IChatProgress[] { const parts: IChatProgress[] = []; for (const rp of activeTurn.responseParts) { @@ -187,7 +187,7 @@ export function activeTurnToProgress(sessionResource: URI, activeTurn: IActiveTu return parts; } -function getTerminalInput(tc: IToolCallState): string | undefined { +function getTerminalInput(tc: ToolCallState): string | undefined { if (tc.status !== ToolCallStatus.Streaming && tc.toolInput) { try { return JSON.parse(tc.toolInput).command || tc.toolInput; @@ -198,12 +198,12 @@ function getTerminalInput(tc: IToolCallState): string | undefined { return undefined; } -function getTerminalOutput(tc: IToolCallState) { +function getTerminalOutput(tc: ToolCallState) { const text = tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Running ? tc.content?.find(c => c.type === 'text')?.text : undefined; return text ? { text } : undefined; } -function getTerminalLanguage(tc: IToolCallState) { +function getTerminalLanguage(tc: ToolCallState) { return tc.toolName === 'powershell' ? 'powershell' : 'shellscript'; } @@ -470,7 +470,7 @@ export function stringOrMarkdownToString(value: StringOrMarkdown | undefined, co * wrapping remote file URIs into `vscode-agent-host:` URIs. Omit to skip * URI wrapping (e.g. in tests that don't exercise the confirmation UI). */ -export function toolCallStateToInvocation(tc: IToolCallState, subAgentInvocationId: string | undefined, sessionResource: URI, connectionAuthority: string | undefined): ChatToolInvocation { +export function toolCallStateToInvocation(tc: ToolCallState, subAgentInvocationId: string | undefined, sessionResource: URI, connectionAuthority: string | undefined): ChatToolInvocation { const toolData: IToolData = { id: tc.toolName, source: ToolDataSource.Internal, @@ -488,6 +488,9 @@ export function toolCallStateToInvocation(tc: IToolCallState, subAgentInvocation title: stringOrMarkdownToString(tc.confirmationTitle, connectionAuthority) ?? tc.displayName, message: stringOrMarkdownToString(tc.invocationMessage, connectionAuthority), }; + if (tc.options) { + confirmationMessages.customOptions = tc.options; + } let toolSpecificData: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatModifiedFilesConfirmationData | undefined; const pendingEdits = tc.edits?.items; @@ -581,7 +584,7 @@ export function toolCallStateToInvocation(tc: IToolCallState, subAgentInvocation * Called from the session handler when a tool transitions to Running state * to set the initial `toolSpecificData`, or when content changes arrive. */ -export function updateRunningToolSpecificData(existing: ChatToolInvocation, tc: IToolCallState, connectionAuthority: string | undefined): void { +export function updateRunningToolSpecificData(existing: ChatToolInvocation, tc: ToolCallState, connectionAuthority: string | undefined): void { if (tc.status !== ToolCallStatus.Running) { return; } @@ -641,7 +644,7 @@ export interface IToolCallFileEdit { * Returns file edits that the caller should route through the editing * session's external edits pipeline. */ -export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: IToolCallState, backendSession: URI, connectionAuthority: string | undefined): IToolCallFileEdit[] { +export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: ToolCallState, backendSession: URI, connectionAuthority: string | undefined): IToolCallFileEdit[] { const isCompleted = tc.status === ToolCallStatus.Completed; const isCancelled = tc.status === ToolCallStatus.Cancelled; const terminalContentUri = tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Completed @@ -706,7 +709,7 @@ export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: ITool * converts them to {@link IToolCallFileEdit} data for routing through * the editing session's external edits pipeline. */ -export function fileEditsToExternalEdits(tc: IToolCallState): IToolCallFileEdit[] { +export function fileEditsToExternalEdits(tc: ToolCallState): IToolCallFileEdit[] { if (tc.status !== ToolCallStatus.Completed) { return []; } @@ -718,12 +721,12 @@ export function fileEditsToExternalEdits(tc: IToolCallState): IToolCallFileEdit[ } /** - * Translates a list of {@link IFileEdit} records into {@link IToolCallFileEdit} + * Translates a list of {@link FileEdit} records into {@link IToolCallFileEdit} * entries suitable for the external edits pipeline or the chat modified-files * confirmation UI. Shared between completed tool edits and pending write * confirmations. */ -function mapFileEdits(items: readonly IFileEdit[], undoStopId: string): IToolCallFileEdit[] { +function mapFileEdits(items: readonly FileEdit[], undoStopId: string): IToolCallFileEdit[] { const result: IToolCallFileEdit[] = []; for (const edit of items) { const isCreate = !edit.before && !!edit.after; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/syncedCustomizationBundler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/syncedCustomizationBundler.ts index 6d3265cf2dc74..29fa125bac4b1 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/syncedCustomizationBundler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/syncedCustomizationBundler.ts @@ -10,7 +10,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { hash } from '../../../../../../base/common/hash.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; -import { type ICustomizationRef } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { type CustomizationRef } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { IAgentHostFileSystemService, SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../../workbench/services/agentHost/common/agentHostFileSystemService.js'; @@ -48,7 +48,7 @@ interface ISyncableFile { } interface IBundleResult { - readonly ref: ICustomizationRef; + readonly ref: CustomizationRef; } /** @@ -98,7 +98,7 @@ export class SyncedCustomizationBundler extends Disposable { /** * Bundles the given files into the in-memory plugin filesystem. * - * Overwrites any previous bundle content. Returns a {@link ICustomizationRef} + * Overwrites any previous bundle content. Returns a {@link CustomizationRef} * pointing at the virtual plugin directory with a content-based nonce. * * @returns The bundle result, or `undefined` if no syncable files were provided. diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsBanner.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsBanner.ts index 5d2389a684ab9..606100bff3844 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsBanner.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsBanner.ts @@ -10,7 +10,8 @@ import { ICommandService, CommandsRegistry } from '../../../../../platform/comma import { IProductService } from '../../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -const OPEN_AGENTS_WINDOW_COMMAND = 'workbench.action.openAgentsWindow'; +import { OPEN_AGENTS_WINDOW_COMMAND_ID } from '../../common/constants.js'; + type AgentsBannerClickedEvent = { source: string; @@ -36,7 +37,7 @@ export interface IAgentsBannerResult { */ export function canShowAgentsBanner(productService: IProductService): boolean { return productService.quality !== 'stable' - && !!CommandsRegistry.getCommand(OPEN_AGENTS_WINDOW_COMMAND); + && !!CommandsRegistry.getCommand(OPEN_AGENTS_WINDOW_COMMAND_ID); } export interface IAgentsBannerOptions { @@ -71,7 +72,7 @@ export function createAgentsBanner( disposables.add(addDisposableListener(button, 'click', () => { options.onButtonClick?.(); telemetryService.publicLog2('agentsBanner.clicked', { source: options.source, action: 'openAgentsWindow' }); - commandService.executeCommand(OPEN_AGENTS_WINDOW_COMMAND, { forceNewWindow: true }); + commandService.executeCommand(OPEN_AGENTS_WINDOW_COMMAND_ID, { forceNewWindow: true }); })); const element = $(`.${options.cssClass}`, {}, button); diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 9fe5a1b13091c..898b5cf48989d 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1337,15 +1337,6 @@ configurationRegistry.registerConfiguration({ disallowConfigurationDefault: true, tags: ['preview', 'prompts', 'hooks', 'agent'] }, - [PromptsConfig.USE_CUSTOM_AGENT_HOOKS]: { - type: 'boolean', - title: nls.localize('chat.useCustomAgentHooks.title', "Use Custom Agent Hooks",), - markdownDescription: nls.localize('chat.useCustomAgentHooks.description', "Controls whether hooks defined in custom agent frontmatter are parsed and executed. When disabled, hooks from agent files are ignored.",), - default: false, - restricted: true, - disallowConfigurationDefault: true, - tags: ['preview', 'prompts', 'hooks', 'agent'] - }, [PromptsConfig.PROMPT_FILES_SUGGEST_KEY]: { type: 'object', scope: ConfigurationScope.RESOURCE, diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/media/chatSessionPickerActionItem.css b/src/vs/workbench/contrib/chat/browser/chatSessions/media/chatSessionPickerActionItem.css index 05791a7f5306b..21013c41fbd47 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/media/chatSessionPickerActionItem.css +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/media/chatSessionPickerActionItem.css @@ -21,6 +21,7 @@ .monaco-action-bar .action-item .chat-session-option-picker { align-items: center; overflow: hidden; + padding: 2px 3px; .chat-session-option-label { overflow: hidden; diff --git a/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts b/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts index 1018e5dbfec49..cd1de3b9f4a83 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts @@ -8,9 +8,8 @@ import { localize } from '../../../../nls.js'; import { ContextKeyExpr, ContextKeyExpression } from '../../../../platform/contextkey/common/contextkey.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { MenuRegistry } from '../../../../platform/actions/common/actions.js'; -import { ProductQualityContext } from '../../../../platform/contextkey/common/contextkeys.js'; +import { OPEN_AGENTS_WINDOW_COMMAND_ID, OPEN_AGENTS_WINDOW_PRECONDITION, ChatConfiguration, ChatModeKind } from '../common/constants.js'; import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; -import { ChatConfiguration, ChatModeKind } from '../common/constants.js'; import { IsSessionsWindowContext } from '../../../common/contextkeys.js'; import { localChatSessionType } from '../common/chatSessionsService.js'; import { ITipExclusionConfig } from './chatTipEligibilityTracker.js'; @@ -419,17 +418,17 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ return new MarkdownString( localize( 'tip.openAgentsWindow', - "Try the [Agents Application](command:workbench.action.openAgentsWindow \"Open Agents Application\") to run multiple agents simultaneously and manage your coding sessions." + "Try the [Agents Application](command:{0} \"Open Agents Application\") to run multiple agents simultaneously and manage your coding sessions.", + OPEN_AGENTS_WINDOW_COMMAND_ID ) ); }, when: ContextKeyExpr.and( - ProductQualityContext.notEqualsTo('stable'), - IsSessionsWindowContext.negate(), + OPEN_AGENTS_WINDOW_PRECONDITION, ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), ), - excludeWhenCommandsExecuted: ['workbench.action.openAgentsWindow'], - dismissWhenCommandsClicked: ['workbench.action.openAgentsWindow'], + excludeWhenCommandsExecuted: [OPEN_AGENTS_WINDOW_COMMAND_ID], + dismissWhenCommandsClicked: [OPEN_AGENTS_WINDOW_COMMAND_ID], }, { id: 'tip.copilotCli', diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts index 244bf9498ef52..180a5e8a25d55 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts @@ -19,7 +19,7 @@ import { Action2, registerAction2 } from '../../../../../platform/actions/common import { Codicon } from '../../../../../base/common/codicons.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; -import { PromptsType, Target } from '../../common/promptSyntax/promptTypes.js'; +import { PromptsType, Target, getSourceDescription } from '../../common/promptSyntax/promptTypes.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { IQuickInputButton, IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; @@ -647,22 +647,27 @@ export async function showConfigureHooksQuickPick( } case Step.SelectFolder: { - // Get source folders for hooks + // Get source folders for hooks (uses getSourceFolders which + // excludes Claude paths and normalizes to directories) const allFolders = await promptsService.getSourceFolders(PromptsType.hook); - const localFolders = allFolders.filter(f => f.storage === PromptsStorage.local); - if (localFolders.length === 0) { + if (allFolders.length === 0) { notificationService.error(localize('commands.hook.noLocalFolders', "Please open a workspace folder to configure hooks.")); return; } // Auto-select if only one folder, otherwise show picker - selectedFolder = localFolders[0]; - if (localFolders.length > 1) { - const folderItems = localFolders.map(folder => ({ - label: labelService.getUriLabel(folder.uri, { relative: true }), - folder - })); + selectedFolder = allFolders[0]; + if (allFolders.length > 1) { + const folderItems = allFolders.map((folder, index) => { + const basePath = labelService.getUriLabel(folder.uri, { relative: folder.storage === PromptsStorage.local }); + const label = index === 0 ? localize('commands.hook.defaultFolder', "{0} (default)", basePath) : basePath; + return { + label, + description: folder.source ? getSourceDescription(folder.source) : undefined, + folder + }; + }); picker.items = folderItems; picker.value = ''; diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts index f6ecdc374158c..dc664a1ffb0b6 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts @@ -3,17 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { extUri, isEqual } from '../../../../../../base/common/resources.js'; +import { isEqual } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ServicesAccessor } from '../../../../../../editor/browser/editorExtensions.js'; import { localize } from '../../../../../../nls.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; -import { PROMPT_DOCUMENTATION_URL, PromptsType } from '../../../common/promptSyntax/promptTypes.js'; +import { PROMPT_DOCUMENTATION_URL, PromptsType, getSourceDescription } from '../../../common/promptSyntax/promptTypes.js'; import { IPickOptions, IQuickInputService, IQuickPickItem } from '../../../../../../platform/quickinput/common/quickInput.js'; -import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IPromptPath, IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; interface IFolderQuickPickItem extends IQuickPickItem { @@ -35,13 +35,13 @@ export async function askForPromptSourceFolder( const labelService = accessor.get(ILabelService); const workspaceService = accessor.get(IWorkspaceContextService); - // get prompts source folders based on the prompt type - const folders = await promptsService.getSourceFolders(type); + // get resolved source folders with full metadata (source, isDefault, displayPath) + const resolvedFolders = await promptsService.getResolvedSourceFolders(type); // if no source folders found, show 'learn more' dialog // note! this is a temporary solution and must be replaced with a dialog to select // a custom folder path, or switch to a different prompt type - if (folders.length === 0) { + if (resolvedFolders.length === 0) { await instantiationService.invokeFunction(accessor => showNoFoldersDialog(accessor, type)); return; } @@ -52,51 +52,57 @@ export async function askForPromptSourceFolder( matchOnDescription: true, }; + // The first folder in the resolved list is the default for new files + const defaultFolder = !existingFolder ? resolvedFolders[0] : undefined; + + const { folders: workspaceFolders } = workspaceService.getWorkspace(); + const isMultiRoot = workspaceFolders.length > 1; + // create list of source folder locations - const foldersList = folders.map(folder => { - const uri = folder.uri; - const detail = (existingFolder && isEqual(uri, existingFolder)) ? localize('current.folder', "Current Location") : undefined; - if (folder.storage !== PromptsStorage.local) { - return { - type: 'item', - label: promptsService.getPromptLocationLabel(folder), - detail, - tooltip: labelService.getUriLabel(uri), - folder - }; - } + const foldersList = resolvedFolders.map(resolved => { + const folderUri = resolved.parent; + const isDefault = defaultFolder && isEqual(folderUri, defaultFolder.parent); + const sourceDescription = getSourceDescription(resolved.source); + const detail = (existingFolder && isEqual(folderUri, existingFolder)) ? localize('current.folder', "Current Location") : undefined; - const { folders } = workspaceService.getWorkspace(); - const isMultirootWorkspace = (folders.length > 1); - - const firstFolder = folders[0]; - - // if multi-root or empty workspace, or source folder `uri` does not point to - // the root folder of a single-root workspace, return the default label and description - if (isMultirootWorkspace || !firstFolder || !extUri.isEqual(firstFolder.uri, uri)) { - return { - type: 'item', - label: labelService.getUriLabel(uri, { relative: true }), - detail, - tooltip: labelService.getUriLabel(uri), - folder, - }; - } + // In multi-root workspaces, use workspace-relative labels (which include + // the workspace folder name prefix). Otherwise use displayPath. + const basePath = (isMultiRoot && resolved.storage === PromptsStorage.local) + ? labelService.getUriLabel(folderUri, { relative: true }) + : resolved.displayPath ?? labelService.getUriLabel(folderUri, { relative: resolved.storage === PromptsStorage.local }); + const label = isDefault ? localize('pathWithDefault', "{0} (default)", basePath) : basePath; + + const folder: IPromptPath = { uri: folderUri, storage: resolved.storage, type }; - // if source folder points to the root of this single-root workspace, - // use appropriate label and description strings to prevent confusion return { - type: 'item', - label: localize( - 'commands.prompts.create.source-folder.current-workspace', - "Current Workspace", - ), + type: 'item' as const, + label, + description: sourceDescription, detail, - tooltip: labelService.getUriLabel(uri), + tooltip: labelService.getUriLabel(folderUri), + picked: isDefault, folder, }; }); + // In multi-root workspaces, sort so items from the same workspace folder + // are grouped together instead of being interleaved by source type. + if (isMultiRoot) { + const getWorkspaceFolderIndex = (uri: URI, storage: PromptsStorage): number => { + if (storage !== PromptsStorage.local) { + return workspaceFolders.length; // global items go last + } + const wsFolder = workspaceService.getWorkspaceFolder(uri); + return wsFolder?.index ?? workspaceFolders.length; + }; + + foldersList.sort((a, b) => { + const aIndex = getWorkspaceFolderIndex(a.folder.uri, a.folder.storage); + const bIndex = getWorkspaceFolderIndex(b.folder.uri, b.folder.storage); + return aIndex - bIndex; + }); + } + const answer = await quickInputService.pick(foldersList, pickOptions); if (!answer) { return; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts index 3a9ac0a040f40..823340ab98abd 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts @@ -10,6 +10,7 @@ import { localize } from '../../../../../../../nls.js'; import { IContextKeyService } from '../../../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../../../../platform/keybinding/common/keybinding.js'; +import { ConfirmationOptionKind, ConfirmationOption } from '../../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ChatContextKeys } from '../../../../common/actions/chatContextKeys.js'; import { ConfirmedReason, IChatToolInvocation, ToolConfirmKind } from '../../../../common/chatService/chatService.js'; import { ILanguageModelToolsService } from '../../../../common/tools/languageModelToolsService.js'; @@ -60,20 +61,14 @@ export abstract class AbstractToolConfirmationSubPart extends BaseChatToolInvoca const { keybindingService, languageModelToolsService, toolInvocation } = this; const state = toolInvocation.state.get(); - const customButtons = state.type === IChatToolInvocation.StateKind.WaitingForConfirmation - ? state.confirmationMessages?.customButtons + const customOptions = state.type === IChatToolInvocation.StateKind.WaitingForConfirmation + ? state.confirmationMessages?.customOptions : undefined; let buttons: IChatConfirmationButton<(() => void)>[]; - if (customButtons && customButtons.length > 0) { - buttons = customButtons.map((label, index) => ({ - label, - data: () => { - this.confirmWith(toolInvocation, { type: ToolConfirmKind.UserAction, selectedButton: label }); - }, - isSecondary: index > 0, - })); + if (customOptions && customOptions.length > 0) { + buttons = this.buildCustomOptionButtons(toolInvocation, customOptions); } else { const allowTooltip = keybindingService.appendKeybinding(config.allowLabel, config.allowActionId); const skipTooltip = keybindingService.appendKeybinding(config.skipLabel, config.skipActionId); @@ -153,6 +148,51 @@ export abstract class AbstractToolConfirmationSubPart extends BaseChatToolInvoca IChatToolInvocation.confirmWith(toolInvocation, reason); } + private buildCustomOptionButtons(toolInvocation: IChatToolInvocation, options: readonly ConfirmationOption[]): IChatConfirmationButton<(() => void)>[] { + const approve: ConfirmationOption[] = []; + const deny: ConfirmationOption[] = []; + for (const option of options) { + (option.kind === ConfirmationOptionKind.Deny ? deny : approve).push(option); + } + + const makeAction = (option: ConfirmationOption): IChatConfirmationButton<(() => void)> => ({ + label: option.label, + data: () => { + this.confirmWith(toolInvocation, { type: ToolConfirmKind.UserAction, selectedButton: option.id }); + }, + }); + + const makeGroupButton = (group: ConfirmationOption[], isSecondary: boolean): IChatConfirmationButton<(() => void)> => { + const [primary, ...rest] = group; + const button: IChatConfirmationButton<(() => void)> = { + ...makeAction(primary), + isSecondary, + }; + if (rest.length > 0) { + const moreActions: (IChatConfirmationButton<(() => void)> | Separator)[] = []; + let prevGroup = primary.group; + for (const option of rest) { + if (option.group !== prevGroup) { + moreActions.push(new Separator()); + } + moreActions.push(makeAction(option)); + prevGroup = option.group; + } + button.moreActions = moreActions; + } + return button; + }; + + const buttons: IChatConfirmationButton<(() => void)>[] = []; + if (approve.length > 0) { + buttons.push(makeGroupButton(approve, false)); + } + if (deny.length > 0) { + buttons.push(makeGroupButton(deny, approve.length > 0)); + } + return buttons; + } + protected additionalPrimaryActions(): AbstractToolPrimaryAction[] { return []; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 2839eba5c83bf..a819caad3f3d3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -1149,8 +1149,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('chat.agent.thinking.collapsedTools'); - if (collapsedToolsMode !== CollapsedToolsDisplayMode.Off && this.shouldPinPart(lastPart, isResponseVM(element) ? element : undefined)) { + if (!isEffectivelyHiddenToolInvocation && collapsedToolsMode !== CollapsedToolsDisplayMode.Off && this.shouldPinPart(lastPart, isResponseVM(element) ? element : undefined)) { return undefined; } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts index 301f601eb77b4..50d0b03e73125 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts @@ -110,7 +110,7 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { ...action, id: `chat.permissions.ext.${sessionTypeSeg}.${groupSeg}.${sanitizeIdSegment(item.id)}`, label: item.name, - description: item.description, + detail: item.description, icon: item.icon, checked: ext.selectedId === item.id, enabled: !item.locked, @@ -131,7 +131,7 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { ...action, id: 'chat.permissions.default', label: localize('permissions.default', "Default Approvals"), - description: localize('permissions.default.subtext', "Copilot uses your configured settings"), + detail: localize('permissions.default.subtext', "Copilot uses your configured settings"), icon: ThemeIcon.fromId(Codicon.shield.id), checked: currentLevel === ChatPermissionLevel.Default, tooltip: '', @@ -149,7 +149,7 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { ...action, id: 'chat.permissions.autoApprove', label: localize('permissions.autoApprove', "Bypass Approvals"), - description: localize('permissions.autoApprove.subtext', "All tool calls are auto-approved"), + detail: localize('permissions.autoApprove.subtext', "All tool calls are auto-approved"), icon: ThemeIcon.fromId(Codicon.warning.id), checked: currentLevel === ChatPermissionLevel.AutoApprove, enabled: !policyRestricted, @@ -208,7 +208,7 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { ...action, id: 'chat.permissions.autopilot', label: localize('permissions.autopilot', "Autopilot (Preview)"), - description: localize('permissions.autopilot.subtext', "Autonomously iterates from start to finish"), + detail: localize('permissions.autopilot.subtext', "Autonomously iterates from start to finish"), icon: ThemeIcon.fromId(Codicon.rocket.id), checked: currentLevel === ChatPermissionLevel.Autopilot, enabled: !policyRestricted, @@ -279,7 +279,7 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { } }], reporter: { id: 'ChatPermissionPicker', name: 'ChatPermissionPicker', includeOptions: true }, - listOptions: { descriptionBelow: true, minWidth: 255 }, + listOptions: { minWidth: 255 }, }, pickerOptions, actionWidgetService, keybindingService, contextKeyService, telemetryService); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 0fc4f63429ec5..643e4e06eecb5 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -872,16 +872,17 @@ have to be updated for changes to the rules above, or to support more deeply nes overflow: hidden; } -/* Animated gradient border shown around the chat input while the agent is +/* Animated "border beam" shown around the chat input while the agent is working or thinking. Toggled by the `chat.progressBorder.enabled` setting and the chat widget's request-in-progress state. - The ring is rendered as a `::before` pseudo-element so it can fade in - and out via `opacity` when the `.working` class is toggled, without - disturbing the input's own background. The pseudo uses a 1px padding - + inverted mask trick to paint a hairline gradient ring that follows - the input's corner radius. The three color stops are themeable via - `chat.inputWorkingBorderColor1/2/3`. */ + Inspired by https://github.com/Jakubantalik/border-beam — a small bright + comet travels around the perimeter, leaving a short fading trail. The + ring is rendered as a `::before` pseudo-element so it can fade in/out + via `opacity` when the `.working` class is toggled, without disturbing + the input's own background. The pseudo uses a 1px padding + inverted + mask trick so the conic gradient is clipped to a hairline that follows + the input's corner radius. */ @property --chat-input-anim-angle { syntax: ''; inherits: false; @@ -898,50 +899,60 @@ have to be updated for changes to the rules above, or to support more deeply nes } } -/* Halo that cycles through the same three theme colors as the conic ring, - in sync with the 3s spin, so the glow appears to emanate from the - rotating gradient. */ -@keyframes chat-input-working-border-glow { - 0% { - box-shadow: - 0 0 10px 0 color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 22%, transparent), - 0 0 22px 2px color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 10%, transparent); - } - - 33% { - box-shadow: - 0 0 10px 0 color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor2) 22%, transparent), - 0 0 22px 2px color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor2) 10%, transparent); - } - - 66% { - box-shadow: - 0 0 10px 0 color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor3) 22%, transparent), - 0 0 22px 2px color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor3) 10%, transparent); - } - - 100% { - box-shadow: - 0 0 10px 0 color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 22%, transparent), - 0 0 22px 2px color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 10%, transparent); - } -} +/* The beam (`::before`) and its glow (`::after`) are two stacked rings + occupying the same outer edge: the glow is wider and blurred, the beam is + hairline and sharp. Both share `--chat-input-anim-angle` so the glow + travels with the comet head with no gap. */ .monaco-workbench .interactive-session .chat-input-container::before { content: ''; position: absolute; - /* Pull the ring out by 1px so its outer edge aligns with where the - `box-shadow` glow on the container begins (the outer border-edge), - eliminating a sub-pixel gap between the rotating ring and the halo. */ - inset: -1px; - border-radius: calc(var(--vscode-cornerRadius-large) - 1px); + /* Sit fully inside the container so the parent's `overflow: hidden` + doesn't clip the beam. */ + inset: 0; + /* Inherit so the ring matches whatever corner radius the container is + currently using (large by default, small in compact mode). */ + border-radius: inherit; padding: 1px; + /* The beam: a tight bright arc (~40deg) with a short fade, on an otherwise + transparent ring. As `--chat-input-anim-angle` rotates, the bright spot + travels around the perimeter like a comet. Stops are mostly transparent + so the rest of the border stays invisible. */ + background: conic-gradient(from var(--chat-input-anim-angle), + transparent 0deg, + color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 90%, transparent) 20deg, + var(--vscode-chat-inputWorkingBorderColor1) 30deg, + color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 60%, transparent) 50deg, + transparent 90deg, + transparent 360deg); + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + opacity: 0; + transition: opacity 350ms ease; + pointer-events: none; + z-index: 2; +} + +/* Glow ring: a 2px blurred conic that shares the beam's angle, so it forms + a soft halo that overlaps the beam line directly — no gap. */ +.monaco-workbench .interactive-session .chat-input-container::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 2px; background: conic-gradient(from var(--chat-input-anim-angle), - var(--vscode-chat-inputWorkingBorderColor1), - var(--vscode-chat-inputWorkingBorderColor2), - var(--vscode-chat-inputWorkingBorderColor3), - var(--vscode-chat-inputWorkingBorderColor2), - var(--vscode-chat-inputWorkingBorderColor1)); + transparent 0deg, + color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 60%, transparent) 25deg, + color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 35%, transparent) 50deg, + transparent 90deg, + transparent 360deg); -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); @@ -950,6 +961,7 @@ have to be updated for changes to the rules above, or to support more deeply nes linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude; + filter: blur(1.5px); opacity: 0; transition: opacity 350ms ease; pointer-events: none; @@ -958,17 +970,22 @@ have to be updated for changes to the rules above, or to support more deeply nes .monaco-workbench .interactive-session .chat-input-container.working { border-color: transparent; - animation: chat-input-working-border-glow 3s linear infinite; } .monaco-workbench .interactive-session .chat-input-container.working::before { opacity: 1; - animation: chat-input-working-border-spin 3s linear infinite; + animation: chat-input-working-border-spin 4s linear infinite; +} + +.monaco-workbench .interactive-session .chat-input-container.working::after { + opacity: 1; + animation: chat-input-working-border-spin 4s linear infinite; } @media (prefers-reduced-motion: reduce) { .monaco-workbench .interactive-session .chat-input-container.working, - .monaco-workbench .interactive-session .chat-input-container.working::before { + .monaco-workbench .interactive-session .chat-input-container.working::before, + .monaco-workbench .interactive-session .chat-input-container.working::after { animation: none; } } @@ -1517,7 +1534,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .chat-secondary-toolbar > .chat-secondary-input-toolbar { overflow: hidden; min-width: 0px; - flex: 1 1 auto; + flex: 1 1 0; color: var(--vscode-icon-foreground); .monaco-action-bar .action-item .codicon { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index c1bb2ebe4c4ee..96c7c595b2abe 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1454,7 +1454,7 @@ export interface IChatSendRequestOptions { rejectedConfirmationData?: any[]; attachedContext?: IChatRequestVariableEntry[]; resolvedVariables?: IChatRequestVariableEntry[]; - agentHostSessionConfig?: Record; + agentHostSessionConfig?: Record; /** The target agent ID can be specified with this property instead of using @ in 'message' */ agentId?: string; diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index f26809501a5f4..d8b405fdd712c 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -6,7 +6,10 @@ import { Schemas } from '../../../../base/common/network.js'; import { IChatSessionsService } from './chatSessionsService.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { IsDevelopmentContext, IsLinuxContext } from '../../../../platform/contextkey/common/contextkeys.js'; +import { ChatEntitlementContextKeys } from '../../../services/chat/common/chatEntitlementService.js'; +import { IsSessionsWindowContext } from '../../../common/contextkeys.js'; export enum ChatConfiguration { AIDisabled = 'chat.disableAIFeatures', @@ -100,8 +103,8 @@ export enum ChatPermissionLevel { const chatPermissionLevels = new Set(Object.values(ChatPermissionLevel)); -export function isChatPermissionLevel(level: string | undefined): level is ChatPermissionLevel { - return level !== undefined && chatPermissionLevels.has(level); +export function isChatPermissionLevel(level: unknown | undefined): level is ChatPermissionLevel { + return chatPermissionLevels.has(level as string); } /** @@ -192,6 +195,15 @@ export function isSupportedChatFileScheme(accessor: ServicesAccessor, scheme: st } export const MANAGE_CHAT_COMMAND_ID = 'workbench.action.chat.manage'; + +export const OPEN_AGENTS_WINDOW_COMMAND_ID = 'workbench.action.openAgentsWindow'; +export const OPEN_AGENTS_WINDOW_PRECONDITION = ContextKeyExpr.and( + ContextKeyExpr.or(IsLinuxContext.negate(), IsDevelopmentContext), + ChatEntitlementContextKeys.Setup.hidden.negate(), + ChatEntitlementContextKeys.Setup.disabledInWorkspace.negate(), + IsSessionsWindowContext.negate(), +); + export const ChatEditorTitleMaxLength = 30; export const CHAT_TERMINAL_OUTPUT_MAX_PREVIEW_LINES = 1000; diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index 14355e3b03cad..25984d05e0355 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -148,7 +148,7 @@ export interface IChatAgentRequest { locationData?: Revived; acceptedConfirmationData?: unknown[]; rejectedConfirmationData?: unknown[]; - agentHostSessionConfig?: Record; + agentHostSessionConfig?: Record; userSelectedModelId?: string; modelConfiguration?: IStringDictionary; userSelectedTools?: UserSelectedTools; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts index 71798f5bbd625..e27d071f1620e 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts @@ -115,11 +115,6 @@ export namespace PromptsConfig { */ export const USE_CLAUDE_HOOKS = 'chat.useClaudeHooks'; - /** - * Configuration key for enabling hooks defined in custom agent frontmatter. - */ - export const USE_CUSTOM_AGENT_HOOKS = 'chat.useCustomAgentHooks'; - /** * Configuration key for enabling stronger skill adherence prompt (experimental). */ @@ -203,7 +198,7 @@ export namespace PromptsConfig { // determine location type in the general case const storage = isTildePath(path) ? PromptsStorage.user : PromptsStorage.local; - paths.push({ path, source: storage === PromptsStorage.local ? PromptFileSource.ConfigPersonal : PromptFileSource.ConfigWorkspace, storage }); + paths.push({ path, source: storage === PromptsStorage.local ? PromptFileSource.ConfigWorkspace : PromptFileSource.ConfigPersonal, storage }); } return paths; 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 7c96b46d11bb1..8a7e9b3012c90 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts @@ -108,12 +108,18 @@ export const CLAUDE_RULES_SOURCE_FOLDER = '.claude/rules'; */ export const HOOKS_SOURCE_FOLDER = '.github/hooks'; +/** + * Subset of {@link PromptFileSource} values that can appear on folder-based + * prompt source configurations (excludes extension/plugin-only sources). + */ +export type PromptFolderSource = Exclude; + /** * Prompt source folder path with source and storage type. */ export interface IPromptSourceFolder { readonly path: string; - readonly source: PromptFileSource; + readonly source: PromptFolderSource; readonly storage: PromptsStorage.local | PromptsStorage.user; } @@ -124,7 +130,7 @@ export interface IResolvedPromptSourceFolder { readonly uri: URI; readonly parent: URI; // matches the URI when no glob pattern is used readonly filePattern: string | undefined; // the part of the path with the glob pattern, or undefined if no glob pattern is used - readonly source: PromptFileSource; + readonly source: PromptFolderSource; readonly storage: PromptsStorage.local | PromptsStorage.user; /** * The original path string before resolution (e.g., '~/.copilot/agents' or '.github/agents'). @@ -141,11 +147,11 @@ export interface IResolvedPromptSourceFolder { * All default skill source folders (both workspace and user home). */ export const DEFAULT_SKILL_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ - { path: '.github/skills', source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, { path: '.agents/skills', source: PromptFileSource.AgentsWorkspace, storage: PromptsStorage.local }, + { path: '.github/skills', source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, { path: '.claude/skills', source: PromptFileSource.ClaudeWorkspace, storage: PromptsStorage.local }, - { path: '~/.copilot/skills', source: PromptFileSource.CopilotPersonal, storage: PromptsStorage.user }, { path: '~/.agents/skills', source: PromptFileSource.AgentsPersonal, storage: PromptsStorage.user }, + { path: '~/.copilot/skills', source: PromptFileSource.CopilotPersonal, storage: PromptsStorage.user }, { path: '~/.claude/skills', source: PromptFileSource.ClaudePersonal, storage: PromptsStorage.user }, ]; @@ -172,8 +178,8 @@ export const DEFAULT_PROMPT_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ export const DEFAULT_AGENT_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ { path: AGENTS_SOURCE_FOLDER, source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, { path: CLAUDE_AGENTS_SOURCE_FOLDER, source: PromptFileSource.ClaudeWorkspace, storage: PromptsStorage.local }, - { path: '~/' + CLAUDE_AGENTS_SOURCE_FOLDER, source: PromptFileSource.ClaudePersonal, storage: PromptsStorage.user }, { path: COPILOT_USER_AGENTS_SOURCE_FOLDER, source: PromptFileSource.CopilotPersonal, storage: PromptsStorage.user }, + { path: '~/' + CLAUDE_AGENTS_SOURCE_FOLDER, source: PromptFileSource.ClaudePersonal, storage: PromptsStorage.user }, ]; /** diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts index de33bb91fd0ce..e277a9eebc3d9 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts @@ -22,8 +22,6 @@ import { formatArrayValue, getQuotePreference } from '../utils/promptEditHelper. import { HOOKS_BY_TARGET, HOOK_METADATA } from '../hookTypes.js'; import { HOOK_COMMAND_FIELD_DESCRIPTIONS } from '../hookSchema.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { PromptsConfig } from '../config/config.js'; export class PromptHeaderAutocompletion implements CompletionItemProvider { /** @@ -42,7 +40,6 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { @ILanguageModelToolsService private readonly languageModelToolsService: ILanguageModelToolsService, @IChatModeService private readonly chatModeService: IChatModeService, @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, - @IConfigurationService private readonly configurationService: IConfigurationService, ) { } @@ -143,9 +140,6 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { const target = getTarget(promptType, header); const attributesToPropose = new Set(getValidAttributeNames(promptType, false, target)); - if (!this.configurationService.getValue(PromptsConfig.USE_CUSTOM_AGENT_HOOKS)) { - attributesToPropose.delete(PromptHeaderAttributes.hooks); - } for (const attr of header.attributes) { attributesToPropose.delete(attr.key); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts index e3d4b279cd93b..00065d1b40c3a 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts @@ -19,8 +19,6 @@ import { IHeaderAttribute, ISequenceValue, parseCommaSeparatedList, PromptBody, import { ClaudeHeaderAttributes, getAttributeDefinition, getTarget, isVSCodeOrDefaultTarget, knownClaudeModels, knownClaudeTools } from './promptFileAttributes.js'; import { HOOKS_BY_TARGET, HOOK_METADATA } from '../hookTypes.js'; import { HOOK_COMMAND_FIELD_DESCRIPTIONS } from '../hookSchema.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { PromptsConfig } from '../config/config.js'; export class PromptHoverProvider implements HoverProvider { /** @@ -33,7 +31,6 @@ export class PromptHoverProvider implements HoverProvider { @ILanguageModelToolsService private readonly languageModelToolsService: ILanguageModelToolsService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @IChatModeService private readonly chatModeService: IChatModeService, - @IConfigurationService private readonly configurationService: IConfigurationService, ) { } @@ -92,9 +89,6 @@ export class PromptHoverProvider implements HoverProvider { case PromptHeaderAttributes.handOffs: return this.getHandsOffHover(attribute, position, target); case PromptHeaderAttributes.hooks: - if (!this.configurationService.getValue(PromptsConfig.USE_CUSTOM_AGENT_HOOKS)) { - return undefined; - } return this.getHooksHover(attribute, position, description, target); case PromptHeaderAttributes.infer: return this.createHover(description + '\n\n' + localize('promptHeader.attribute.infer.hover', 'Deprecated: Use `user-invocable` and `disable-model-invocation` instead.'), attribute.range); 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 7919e29d3f785..e34daa4869c1c 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -7,7 +7,6 @@ import { isEmptyPattern, parse, splitGlobAware } from '../../../../../../base/co import { Iterable } from '../../../../../../base/common/iterator.js'; import { Range } from '../../../../../../editor/common/core/range.js'; import { localize } from '../../../../../../nls.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IMarkerData, MarkerSeverity, MarkerTag } from '../../../../../../platform/markers/common/markers.js'; import { ChatMode, IChatMode, IChatModeService } from '../../chatModes.js'; import { ChatModeKind } from '../../constants.js'; @@ -24,8 +23,8 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js import { dirname } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { HOOKS_BY_TARGET } from '../hookTypes.js'; -import { PromptsConfig } from '../config/config.js'; import { GithubPromptHeaderAttributes } from './promptFileAttributes.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; export const MARKERS_OWNER_ID = 'prompts-diagnostics-provider'; @@ -37,7 +36,7 @@ export class PromptValidator { @IFileService private readonly fileService: IFileService, @ILabelService private readonly labelService: ILabelService, @IPromptsService private readonly promptsService: IPromptsService, - @IConfigurationService private readonly configurationService: IConfigurationService + @ILogService private readonly logger: ILogService, ) { } public async validate(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise { @@ -132,13 +131,13 @@ export class PromptValidator { fileReferenceChecks.push((async () => { try { const exists = await this.fileService.exists(resolved); - if (exists) { - return; + if (!exists) { + const loc = this.labelService.getUriLabel(resolved); + report(toMarker(localize('promptValidator.fileNotFound', "File '{0}' not found at '{1}'.", ref.content, loc), ref.range, MarkerSeverity.Warning)); } } catch { + this.logger.warn(`Error checking existence of file reference '${ref.content}' resolved to '${resolved.toString()}' in prompt file '${promptAST.uri.toString()}'`); } - const loc = this.labelService.getUriLabel(resolved); - report(toMarker(localize('promptValidator.fileNotFound', "File '{0}' not found at '{1}'.", ref.content, loc), ref.range, MarkerSeverity.Warning)); })()); } } @@ -211,9 +210,7 @@ export class PromptValidator { this.validateUserInvocable(attributes, report); this.validateDisableModelInvocation(attributes, report); this.validateTools(attributes, ChatModeKind.Agent, target, report); - if (this.configurationService.getValue(PromptsConfig.USE_CUSTOM_AGENT_HOOKS)) { - this.validateHooks(attributes, target, report); - } + this.validateHooks(attributes, target, report); if (isVSCodeOrDefaultTarget(target)) { this.validateModel(attributes, ChatModeKind.Agent, report); this.validateHandoffs(attributes, report); @@ -235,19 +232,12 @@ export class PromptValidator { } private checkForInvalidArguments(attributes: IHeaderAttribute[], promptType: PromptsType, target: Target, report: (markers: IMarkerData) => void): void { - let validAttributeNames = getValidAttributeNames(promptType, true, target); - if (!this.configurationService.getValue(PromptsConfig.USE_CUSTOM_AGENT_HOOKS)) { - validAttributeNames = validAttributeNames.filter(name => name !== PromptHeaderAttributes.hooks); - } - const useCustomAgentHooks = this.configurationService.getValue(PromptsConfig.USE_CUSTOM_AGENT_HOOKS); + const validAttributeNames = getValidAttributeNames(promptType, true, target); const validGithubCopilotAttributeNames = new Lazy(() => new Set(getValidAttributeNames(promptType, false, Target.GitHubCopilot))); for (const attribute of attributes) { if (!validAttributeNames.includes(attribute.key)) { const supportedNames = new Lazy(() => { - let names = getValidAttributeNames(promptType, false, target); - if (!useCustomAgentHooks) { - names = names.filter(name => name !== PromptHeaderAttributes.hooks); - } + const names = getValidAttributeNames(promptType, false, target); return names.sort().join(', '); }); switch (promptType) { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts index c86416d017040..db6ceedacb8f2 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { LanguageSelector } from '../../../../../editor/common/languageSelector.js'; +import { localize } from '../../../../../nls.js'; /** * Documentation link for the reusable prompts feature. @@ -137,7 +138,38 @@ export enum PromptFileSource { AgentsPersonal = 'agents-personal', ConfigWorkspace = 'config-workspace', ConfigPersonal = 'config-personal', + UserData = 'user-data', ExtensionContribution = 'extension-contribution', ExtensionAPI = 'extension-api', Plugin = 'plugin', } + +/** + * Returns a human-readable description for a prompt file source. + */ +export function getSourceDescription(source: PromptFileSource): string | undefined { + switch (source) { + case PromptFileSource.AgentsWorkspace: + return localize('source.agentsWorkspace', "Workspace"); + case PromptFileSource.AgentsPersonal: + return localize('source.agentsPersonal', "Global"); + case PromptFileSource.GitHubWorkspace: + return localize('source.githubWorkspace', "Workspace (only used by Copilot agents)"); + case PromptFileSource.CopilotPersonal: + return localize('source.copilotPersonal', "Global (only used by Copilot agents)"); + case PromptFileSource.ClaudeWorkspace: + return localize('source.claudeWorkspace', "Workspace (only used by Claude agents)"); + case PromptFileSource.ClaudeWorkspaceLocal: + return localize('source.claudeWorkspaceLocal', "Workspace (only used by Claude agents, usually git-ignored)"); + case PromptFileSource.ClaudePersonal: + return localize('source.claudePersonal', "Global (only used by Claude agents)"); + case PromptFileSource.UserData: + return localize('source.userData', "Global (roams with Settings Sync, only used by VS Code)"); + case PromptFileSource.ConfigWorkspace: + return localize('source.configWorkspace', "Workspace (contributed from settings)"); + case PromptFileSource.ConfigPersonal: + return localize('source.configPersonal', "Global (contributed from settings)"); + default: + return undefined; + } +} 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 645989eb30acd..07b6cc4961afb 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -216,9 +216,10 @@ export class PromptsService extends Disposable implements IPromptsService { () => Event.any( this.getFileLocatorEvent(PromptsType.agent), Event.filter(modelChangeEvent, e => e.promptType === PromptsType.agent), + Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(PromptsConfig.USE_CHAT_HOOKS)), this._onDidContributedWhenChange.event, - Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(PromptsConfig.USE_CUSTOM_AGENT_HOOKS)), this._onDidPluginPromptFilesChange.event, + this.workspaceTrustService.onDidChangeTrust, ) )); @@ -580,8 +581,8 @@ export class PromptsService extends Disposable implements IPromptsService { // For hooks, return the Copilot hooks folder for creating new hooks // (Claude paths are read-only and not included here) const hooksFolders = await this.fileLocator.getHookSourceFolders(); - for (const uri of hooksFolders) { - result.push({ uri, storage: PromptsStorage.local, type }); + for (const folder of hooksFolders) { + result.push({ uri: folder.uri, storage: folder.storage, type, source: folder.source }); } } else { for (const uri of await this.fileLocator.getConfigBasedSourceFolders(type)) { @@ -772,6 +773,8 @@ export class PromptsService extends Disposable implements IPromptsService { const stopWatch = StopWatch.create(true); const allAgentFiles = await this.listPromptFiles(PromptsType.agent, token); const disabledAgents = this.getDisabledPromptFiles(PromptsType.agent); + const useChatHooks = this.configurationService.getValue(PromptsConfig.USE_CHAT_HOOKS); + const isWorkspaceTrusted = this.workspaceTrustService.isWorkspaceTrusted(); // Get user home for tilde expansion in hook cwd paths const userHomeUri = await this.pathService.userHome(); @@ -846,9 +849,8 @@ export class PromptsService extends Disposable implements IPromptsService { // Parse hooks from the frontmatter if present let hooks: ChatRequestHooks | undefined; - const useCustomAgentHooks = this.configurationService.getValue(PromptsConfig.USE_CUSTOM_AGENT_HOOKS); const hooksRaw = ast.header.hooksRaw; - if (useCustomAgentHooks && hooksRaw) { + if (useChatHooks && isWorkspaceTrusted && hooksRaw) { const hookWorkspaceFolder = this.workspaceService.getWorkspaceFolder(uri) ?? defaultFolder; const workspaceRootUri = hookWorkspaceFolder?.uri; hooks = parseSubagentHooksFromYaml(hooksRaw, workspaceRootUri, userHome, target); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts index bc793a2aa2c78..1b9e2005c6867 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -63,7 +63,7 @@ export class PromptFilesLocator { uri: userDataPromptsHome, parent: userDataPromptsHome, filePattern: undefined, - source: PromptFileSource.CopilotPersonal, + source: PromptFileSource.UserData, storage: PromptsStorage.user, displayPath: nls.localize('promptsUserDataFolder', "User Data"), isDefault: true @@ -269,7 +269,7 @@ export class PromptFilesLocator { * Gets the hook source folders for creating new hooks. * Returns configured hook folders, excluding Claude paths (which are read-only). */ - public async getHookSourceFolders(): Promise { + public async getHookSourceFolders(): Promise { const configuredLocations = this.getPromptSourceFolders(PromptsType.hook); // Ignore claude folders since they aren't first-class supported, so we don't want to create invalid formats @@ -278,18 +278,23 @@ export class PromptFilesLocator { !loc.path.startsWith('.claude/') && !loc.path.includes('/.claude/') ); - // Convert to absolute URIs - const result = new ResourceSet(); + // Convert to absolute locations with metadata const absoluteLocations = await this.toAbsoluteLocations(PromptsType.hook, allowedHookFolders); + // Deduplicate by parent URI, keeping the first occurrence + const seen = new ResourceSet(); + const result: IResolvedPromptSourceFolder[] = []; for (const location of absoluteLocations) { // For hook configs, entries are directories unless the path ends with .json (specific file) // Default entries have filePattern, user entries don't but are still directories // location.parent points to the directory in both cases, so we can just use that - result.add(location.parent); + if (!seen.has(location.parent)) { + seen.add(location.parent); + result.push({ ...location, uri: location.parent, filePattern: undefined }); + } } - return [...result]; + return result; } /** diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/confirmationTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/confirmationTool.ts index 2660841ac5b8e..169fa6ed322d3 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/confirmationTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/confirmationTool.ts @@ -6,6 +6,7 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { ConfirmationOptionKind } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { IChatModifiedFilesConfirmationData, IChatTerminalToolInvocationData } from '../../chatService/chatService.js'; import { CountTokensCallback, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolInvocationPresentation, ToolProgress } from '../languageModelToolsService.js'; @@ -187,7 +188,11 @@ export class ConfirmationTool implements IToolImpl { title: parameters.title, message: new MarkdownString(parameters.message), allowAutoConfirm: (parameters.buttons || []).length ? false : true, // We cannot auto confirm if there are custom buttons, as we don't know which one to select - customButtons: parameters.buttons, + customOptions: parameters.buttons?.map((label, index) => ({ + id: label, + label, + kind: index === 0 ? ConfirmationOptionKind.Approve : ConfirmationOptionKind.Deny, + })), }, toolSpecificData, presentation: ToolInvocationPresentation.HiddenAfterComplete diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 248de139c5f0f..0b32734757b42 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -17,6 +17,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { Location } from '../../../../../editor/common/languages.js'; import { localize } from '../../../../../nls.js'; +import { ConfirmationOption } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { ContextKeyExpression, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { ByteSize } from '../../../../../platform/files/common/files.js'; @@ -328,8 +329,8 @@ export interface IToolConfirmationMessages { confirmResults?: boolean; /** If title is not set (no confirmation needed), this reason will be shown to explain why confirmation was not needed */ confirmationNotNeededReason?: string | IMarkdownString; - /** Custom button labels to display instead of the default Allow/Skip buttons. */ - customButtons?: string[]; + /** Custom options to display instead of the default Allow/Skip buttons. */ + customOptions?: ConfirmationOption[]; /** When set, shows an additional approval option to approve this particular combination of tool and arguments */ approveCombination?: { /** Human-readable label for the approval option */ diff --git a/src/vs/workbench/contrib/chat/common/widget/chatColors.ts b/src/vs/workbench/contrib/chat/common/widget/chatColors.ts index 411ea7e704ad2..196456d168702 100644 --- a/src/vs/workbench/contrib/chat/common/widget/chatColors.ts +++ b/src/vs/workbench/contrib/chat/common/widget/chatColors.ts @@ -6,6 +6,8 @@ import { Color, RGBA } from '../../../../../base/common/color.js'; import { localize } from '../../../../../nls.js'; import { badgeBackground, badgeForeground, contrastBorder, editorBackground, editorSelectionBackground, editorWidgetBackground, foreground, registerColor, transparent } from '../../../../../platform/theme/common/colorRegistry.js'; +import { buttonBackground } from '../../../../../platform/theme/common/colors/inputColors.js'; +import { darken, lighten } from '../../../../../platform/theme/common/colorUtils.js'; // This color intentionally matches commandCenter.background but is separate so that it // doesn't get overridden when debugging (the debug toolbar overrides commandCenter.background). @@ -90,15 +92,15 @@ export const chatThinkingShimmer = registerColor( export const chatInputWorkingBorderColor1 = registerColor( 'chat.inputWorkingBorderColor1', - { dark: '#E8E8EC', light: '#B8B8C0', hcDark: '#FFFFFF', hcLight: '#000000' }, + { dark: buttonBackground, light: buttonBackground, hcDark: '#FFFFFF', hcLight: '#000000' }, localize('chat.inputWorkingBorderColor1', 'First color stop of the animated chat input border shown while a request is in flight.'), true); export const chatInputWorkingBorderColor2 = registerColor( 'chat.inputWorkingBorderColor2', - { dark: '#8A8A92', light: '#7A7A82', hcDark: '#A0A0A0', hcLight: '#555555' }, - localize('chat.inputWorkingBorderColor2', 'Second color stop of the animated chat input border shown while a request is in flight.'), true); + { dark: darken(buttonBackground, 0.5), light: darken(buttonBackground, 0.3), hcDark: '#A0A0A0', hcLight: '#555555' }, + localize('chat.inputWorkingBorderColor2', 'Secondary accent color used by other animated chat input affordances. Not used by the in-flight chat input border.'), true); export const chatInputWorkingBorderColor3 = registerColor( 'chat.inputWorkingBorderColor3', - { dark: '#3A3A40', light: '#2E2E34', hcDark: '#000000', hcLight: '#000000' }, - localize('chat.inputWorkingBorderColor3', 'Third color stop of the animated chat input border shown while a request is in flight.'), true); + { dark: lighten(buttonBackground, 0.5), light: lighten(buttonBackground, 0.3), hcDark: '#000000', hcLight: '#000000' }, + localize('chat.inputWorkingBorderColor3', 'Tertiary accent color used by other animated chat input affordances. Not used by the in-flight chat input border.'), true); diff --git a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts index 5dbfa3278e529..f4a0786c4cfcf 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts @@ -5,28 +5,25 @@ import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; import { localize2 } from '../../../../../nls.js'; import { Action2, MenuId } from '../../../../../platform/actions/common/actions.js'; -import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { INativeHostService } from '../../../../../platform/native/common/native.js'; -import { ChatEntitlementContextKeys } from '../../../../services/chat/common/chatEntitlementService.js'; import { CHAT_CATEGORY } from '../../browser/actions/chatActions.js'; -import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; import { isMacintosh, isWindows } from '../../../../../base/common/platform.js'; import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; -import { ProductQualityContext } from '../../../../../platform/contextkey/common/contextkeys.js'; +import { OPEN_AGENTS_WINDOW_COMMAND_ID, OPEN_AGENTS_WINDOW_PRECONDITION } from '../../common/constants.js'; export class OpenAgentsWindowAction extends Action2 { constructor() { super({ - id: 'workbench.action.openAgentsWindow', + id: OPEN_AGENTS_WINDOW_COMMAND_ID, title: localize2('openAgentsWindow', "Open Agents Application"), category: CHAT_CATEGORY, - precondition: ContextKeyExpr.and(ProductQualityContext.notEqualsTo('stable'), ChatEntitlementContextKeys.Setup.hidden.negate(), ChatEntitlementContextKeys.Setup.disabledInWorkspace.negate(), IsSessionsWindowContext.negate()), + precondition: OPEN_AGENTS_WINDOW_PRECONDITION, f1: true, menu: [{ id: MenuId.ChatTitleBarMenu, group: 'c_sessions', order: 1, - when: ContextKeyExpr.and(ProductQualityContext.notEqualsTo('stable'), ChatEntitlementContextKeys.Setup.hidden.negate(), ChatEntitlementContextKeys.Setup.disabledInWorkspace.negate(), IsSessionsWindowContext.negate()) + when: OPEN_AGENTS_WINDOW_PRECONDITION, }] }); } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostEditingSession.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostEditingSession.test.ts index c760e13185c0c..0e70c566de149 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostEditingSession.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostEditingSession.test.ts @@ -15,8 +15,8 @@ import { DetailedLineRangeMapping } from '../../../../../../editor/common/diff/r import { IEditorWorkerService } from '../../../../../../editor/common/services/editorWorker.js'; import { IResolvedTextEditorModel, ITextModelService } from '../../../../../../editor/common/services/resolverService.js'; import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; -import { IToolCallState, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import type { IToolCallCompletedState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ToolCallState, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import type { ToolCallCompletedState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IFileContent, IFileService } from '../../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { NullLogService } from '../../../../../../platform/log/common/log.js'; @@ -56,7 +56,7 @@ function makeToolCall(opts: { afterURI: string; added?: number; removed?: number; -}): IToolCallCompletedState { +}): ToolCallCompletedState { return { status: ToolCallStatus.Completed, toolCallId: opts.toolCallId, @@ -196,7 +196,7 @@ suite('AgentHostEditingSession', () => { test('addToolCallEdits ignores non-completed tool calls', () => { const session = createSession(store, new Map()); - const tc = { ...makeToolCall({ toolCallId: 'tc-1', filePath: '/f.ts', beforeURI: 'b', afterURI: 'a' }), status: ToolCallStatus.Running } as IToolCallState; + const tc = { ...makeToolCall({ toolCallId: 'tc-1', filePath: '/f.ts', beforeURI: 'b', afterURI: 'a' }), status: ToolCallStatus.Running } as ToolCallState; session.addToolCallEdits('req-1', tc); assert.strictEqual(session.state.get(), ChatEditingSessionState.Idle); 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 e5056e5e59ee7..368e910ef0811 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,10 +16,10 @@ 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 { AgentHostSessionConfigBranchNameHintKey, IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; -import { isSessionAction, type IActionEnvelope, type INotification, type ISessionAction, type ITerminalAction, type IToolCallConfirmedAction, type ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { isSessionAction, type ActionEnvelope, type INotification, 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 { ICustomizationRef } 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 ISessionState, type ISessionSummary, IRootState, type IToolCallState } from '../../../../../../platform/agentHost/common/state/sessionState.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 } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { sessionReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; @@ -56,7 +56,7 @@ import { ILanguageModelToolsService } from '../../../common/tools/languageModelT class MockAgentHostService extends mock() { declare readonly _serviceBrand: undefined; - private readonly _onDidAction = new Emitter(); + private readonly _onDidAction = new Emitter(); override readonly onDidAction = this._onDidAction.event; private readonly _onDidNotification = new Emitter(); override readonly onDidNotification = this._onDidNotification.event; @@ -70,7 +70,7 @@ class MockAgentHostService extends mock() { } // Track live subscriptions so fireAction can route to them - private readonly _liveSubscriptions = new Map }>(); + private readonly _liveSubscriptions = new Map }>(); private _nextId = 1; private readonly _sessions = new Map(); @@ -92,7 +92,7 @@ class MockAgentHostService extends mock() { // Simulate the server's eager active-client claim: if the caller // provided activeClient, seed the session state so subscribers see it. if (config?.activeClient) { - const summary: ISessionSummary = { + const summary: SessionSummary = { resource: session.toString(), provider: 'copilot', title: 'Test', @@ -100,7 +100,7 @@ class MockAgentHostService extends mock() { createdAt: Date.now(), modifiedAt: Date.now(), }; - const state: ISessionState = { + const state: SessionState = { ...createSessionState(summary), lifecycle: SessionLifecycle.Ready, activeClient: config.activeClient, @@ -116,14 +116,14 @@ class MockAgentHostService extends mock() { // Protocol methods public override readonly clientId = 'test-window-1'; - public dispatchedActions: { action: ISessionAction | ITerminalAction; clientId: string; clientSeq: number }[] = []; + public dispatchedActions: { action: SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = []; /** Returns dispatched actions filtered to turn-related types only * (excludes lifecycle actions like activeClientChanged). */ get turnActions() { return this.dispatchedActions.filter(d => d.action.type === 'session/turnStarted'); } - public sessionStates = new Map(); + public sessionStates = new Map(); async subscribe(resource: URI): Promise { const resourceStr = resource.toString(); const existingState = this.sessionStates.get(resourceStr); @@ -141,7 +141,7 @@ class MockAgentHostService extends mock() { fromSeq: 0, }; } - const summary: ISessionSummary = { + const summary: SessionSummary = { resource: resourceStr, provider: 'copilot', title: 'Test', @@ -156,7 +156,7 @@ class MockAgentHostService extends mock() { }; } unsubscribe(_resource: URI): void { } - dispatchAction(action: ISessionAction | ITerminalAction, clientId: string, clientSeq: number): void { + dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void { this.dispatchedActions.push({ action, clientId, clientSeq }); } private _nextSeq = 1; @@ -164,7 +164,7 @@ class MockAgentHostService extends mock() { return this._nextSeq++; } - override readonly rootState: IAgentSubscription = { + override readonly rootState: IAgentSubscription = { value: undefined, verifiedValue: undefined, onDidChange: Event.None, @@ -174,16 +174,16 @@ class MockAgentHostService extends mock() { override getSubscription(_kind: StateComponents, resource: URI): IReference> { const resourceStr = resource.toString(); const emitter = new Emitter(); - const onWillApply = new Emitter(); - const onDidApply = new Emitter(); + const onWillApply = new Emitter(); + const onDidApply = new Emitter(); // Hydrate synchronously with a default state const existingState = this.sessionStates.get(resourceStr); - let initialState: ISessionState; + let initialState: SessionState; if (existingState) { initialState = existingState; } else { - const summary: ISessionSummary = { + const summary: SessionSummary = { resource: resourceStr, provider: 'copilot', title: 'Test', @@ -195,7 +195,7 @@ class MockAgentHostService extends mock() { } // Register in live subscriptions so fireAction can route to it - const entry = { state: initialState, emitter: emitter as unknown as Emitter }; + const entry = { state: initialState, emitter: emitter as unknown as Emitter }; this._liveSubscriptions.set(resourceStr, entry); const self = this; @@ -230,7 +230,7 @@ class MockAgentHostService extends mock() { onDidApplyAction: Event.None, } satisfies IAgentSubscription; } - override dispatch(action: ISessionAction | ITerminalAction): void { + override dispatch(action: 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. @@ -247,7 +247,7 @@ class MockAgentHostService extends mock() { } // Test helpers - fireAction(envelope: IActionEnvelope): void { + fireAction(envelope: ActionEnvelope): void { this._onDidAction.fire(envelope); // Route action to matching live subscriptions if (isSessionAction(envelope.action)) { @@ -462,7 +462,7 @@ async function startTurn( const session = (lastDispatch?.action as ITurnStartedAction)?.session; const turnId = (lastDispatch?.action as ITurnStartedAction)?.turnId; - const fire = (action: ISessionAction) => { + const fire = (action: SessionAction) => { agentHostService.fireAction({ action, serverSeq: seq.v++, origin: undefined }); }; @@ -520,7 +520,7 @@ async function startDynamicAgentTurn( const lastDispatch = turnDispatches[turnDispatches.length - 1] ?? agentHostService.dispatchedActions[agentHostService.dispatchedActions.length - 1]; const session = (lastDispatch?.action as ITurnStartedAction)?.session; const turnId = (lastDispatch?.action as ITurnStartedAction)?.turnId; - const fire = (action: ISessionAction) => { + const fire = (action: SessionAction) => { agentHostService.fireAction({ action, serverSeq: seq.v++, origin: undefined }); }; @@ -628,7 +628,7 @@ suite('AgentHostChatContribution', () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { message: 'Hello' }); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -659,7 +659,7 @@ suite('AgentHostChatContribution', () => { const action1 = dispatch1.action as ITurnStartedAction; // Echo the turnStarted to clear pending write-ahead agentHostService.fireAction({ action: dispatch1.action, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: dispatch1.clientSeq } }); - agentHostService.fireAction({ action: { type: 'session/turnComplete', session: action1.session, turnId: action1.turnId } as ISessionAction, serverSeq: 2, origin: undefined }); + agentHostService.fireAction({ action: { type: 'session/turnComplete', session: action1.session, turnId: action1.turnId } as SessionAction, serverSeq: 2, origin: undefined }); await turn1Promise; // Second turn @@ -671,7 +671,7 @@ suite('AgentHostChatContribution', () => { const dispatch2 = agentHostService.turnActions[1]; const action2 = dispatch2.action as ITurnStartedAction; agentHostService.fireAction({ action: dispatch2.action, serverSeq: 3, origin: { clientId: agentHostService.clientId, clientSeq: dispatch2.clientSeq } }); - agentHostService.fireAction({ action: { type: 'session/turnComplete', session: action2.session, turnId: action2.turnId } as ISessionAction, serverSeq: 4, origin: undefined }); + agentHostService.fireAction({ action: { type: 'session/turnComplete', session: action2.session, turnId: action2.turnId } as SessionAction, serverSeq: 4, origin: undefined }); await turn2Promise; assert.strictEqual(agentHostService.turnActions.length, 2); @@ -688,7 +688,7 @@ suite('AgentHostChatContribution', () => { message: 'Hi', sessionResource: URI.from({ scheme: 'agent-host-copilot', path: '/existing-session-42' }), }); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; assert.strictEqual(AgentSession.id(URI.parse(session)), 'existing-session-42'); @@ -701,7 +701,7 @@ suite('AgentHostChatContribution', () => { message: 'Hi', sessionResource: URI.from({ scheme: 'agent-host-copilot', path: '/untitled-abc123' }), }); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; // Should create a new SDK session, not use "untitled-abc123" literally @@ -714,7 +714,7 @@ suite('AgentHostChatContribution', () => { message: 'Hi', userSelectedModelId: 'agent-host-copilot:claude-sonnet-4-20250514', }); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); @@ -729,7 +729,7 @@ suite('AgentHostChatContribution', () => { userSelectedModelId: 'agent-host-copilot:claude-sonnet-4-20250514', modelConfiguration: { thinkingLevel: 'high', ignored: 1 }, }); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); @@ -743,7 +743,7 @@ suite('AgentHostChatContribution', () => { message: 'Hi', userSelectedModelId: 'gpt-4o', }); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); @@ -771,9 +771,9 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); - fire({ type: 'session/responsePart', session, turnId, part: { kind: 'markdown', id: 'md-1', content: 'hello ' } } as ISessionAction); - fire({ type: 'session/delta', session, turnId, partId: 'md-1', content: 'world' } as ISessionAction); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/responsePart', session, turnId, part: { kind: 'markdown', id: 'md-1', content: 'hello ' } } as SessionAction); + fire({ type: 'session/delta', session, turnId, partId: 'md-1', content: 'world' } as SessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; @@ -788,9 +788,9 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); - fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-1', toolName: 'read_file', displayName: 'Read File' } as ISessionAction); - fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-1', invocationMessage: 'Reading file', confirmed: 'not-needed' } as ISessionAction); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-1', toolName: 'read_file', displayName: 'Read File' } as SessionAction); + fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-1', invocationMessage: 'Reading file', confirmed: 'not-needed' } as SessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; @@ -803,13 +803,13 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); - fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-2', toolName: 'bash', displayName: 'Bash' } as ISessionAction); - fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-2', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as ISessionAction); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-2', toolName: 'bash', displayName: 'Bash' } as SessionAction); + fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-2', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as SessionAction); fire({ type: 'session/toolCallComplete', session, turnId, toolCallId: 'tc-2', result: { success: true, pastTenseMessage: 'Ran Bash command' }, - } as ISessionAction); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + } as SessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; @@ -825,13 +825,13 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); - fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-3', toolName: 'bash', displayName: 'Bash' } as ISessionAction); - fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-3', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as ISessionAction); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-3', toolName: 'bash', displayName: 'Bash' } as SessionAction); + fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-3', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as SessionAction); fire({ type: 'session/toolCallComplete', session, turnId, toolCallId: 'tc-3', result: { success: false, pastTenseMessage: '"Bash" failed', content: [{ type: 'text', text: 'command not found' }], error: { message: 'command not found' } }, - } as ISessionAction); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + } as SessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; @@ -846,9 +846,9 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); - fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-bad', toolName: 'bash', displayName: 'Bash' } as ISessionAction); - fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-bad', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as ISessionAction); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-bad', toolName: 'bash', displayName: 'Bash' } as SessionAction); + fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-bad', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as SessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; @@ -862,9 +862,9 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); // tool_start without tool_complete - fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-orphan', toolName: 'bash', displayName: 'Bash' } as ISessionAction); - fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-orphan', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as ISessionAction); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-orphan', toolName: 'bash', displayName: 'Bash' } as SessionAction); + fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-orphan', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as SessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; @@ -881,12 +881,12 @@ suite('AgentHostChatContribution', () => { // Delta from a different session — will be ignored (session not subscribed) agentHostService.fireAction({ - action: { type: 'session/delta', session: AgentSession.uri('copilot', 'other-session').toString(), turnId, partId: 'md-other', content: 'wrong' } as ISessionAction, + action: { type: 'session/delta', session: AgentSession.uri('copilot', 'other-session').toString(), turnId, partId: 'md-other', content: 'wrong' } as SessionAction, serverSeq: 100, origin: undefined, }); - fire({ type: 'session/responsePart', session, turnId, part: { kind: 'markdown', id: 'md-1', content: 'right' } } as ISessionAction); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/responsePart', session, turnId, part: { kind: 'markdown', id: 'md-1', content: 'right' } } as SessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; @@ -925,8 +925,8 @@ suite('AgentHostChatContribution', () => { cancellationToken: cts.token, }); - fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-cancel', toolName: 'bash', displayName: 'Bash' } as ISessionAction); - fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-cancel', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as ISessionAction); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-cancel', toolName: 'bash', displayName: 'Bash' } as SessionAction); + fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-cancel', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as SessionAction); cts.cancel(); await turnPromise; @@ -972,7 +972,7 @@ suite('AgentHostChatContribution', () => { session, turnId, error: { errorType: 'test_error', message: 'Something went wrong' }, - } as ISessionAction, + } as SessionAction, serverSeq: 99, origin: undefined, }); @@ -996,11 +996,11 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); // Simulate a tool call requiring confirmation via toolCallStart + toolCallReady - fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-perm-1', toolName: 'shell', displayName: 'Shell' } as ISessionAction); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-perm-1', toolName: 'shell', displayName: 'Shell' } as SessionAction); fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-perm-1', invocationMessage: 'echo hello', toolInput: 'echo hello', - } as ISessionAction); + } as SessionAction); await timeout(10); @@ -1028,7 +1028,7 @@ suite('AgentHostChatContribution', () => { } )); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; })); @@ -1037,11 +1037,11 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); - fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-perm-2', toolName: 'write', displayName: 'Write File' } as ISessionAction); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-perm-2', toolName: 'write', displayName: 'Write File' } as SessionAction); fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-perm-2', invocationMessage: 'Write to /tmp/test.txt', - } as ISessionAction); + } as SessionAction); await timeout(10); @@ -1062,7 +1062,7 @@ suite('AgentHostChatContribution', () => { } )); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; })); @@ -1071,11 +1071,11 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); - fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-perm-shell', toolName: 'shell', displayName: 'Shell' } as ISessionAction); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-perm-shell', toolName: 'shell', displayName: 'Shell' } as SessionAction); fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-perm-shell', invocationMessage: 'echo hello', toolInput: 'echo hello', - } as ISessionAction); + } as SessionAction); await timeout(10); const toolInvocations = collected.flat().filter(p => p.kind === 'toolInvocation'); @@ -1086,7 +1086,7 @@ suite('AgentHostChatContribution', () => { IChatToolInvocation.confirmWith(permInvocation, { type: ToolConfirmKind.UserAction }); await timeout(10); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; })); @@ -1095,18 +1095,18 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); - fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-perm-read', toolName: 'read_file', displayName: 'Read File' } as ISessionAction); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-perm-read', toolName: 'read_file', displayName: 'Read File' } as SessionAction); fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-perm-read', invocationMessage: 'Read file contents', toolInput: '/workspace/file.ts', - } as ISessionAction); + } as SessionAction); await timeout(10); const permInvocation = collected[0][0] as IChatToolInvocation; IChatToolInvocation.confirmWith(permInvocation, { type: ToolConfirmKind.UserAction }); await timeout(10); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; })); }); @@ -1171,13 +1171,13 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); - fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-shell', toolName: 'bash', displayName: 'Bash', _meta: { toolKind: 'terminal', language: 'shellscript' } } as ISessionAction); - fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-shell', invocationMessage: 'Running `echo hello`', toolInput: 'echo hello', confirmed: 'not-needed' } as ISessionAction); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-shell', toolName: 'bash', displayName: 'Bash', _meta: { toolKind: 'terminal', language: 'shellscript' } } as SessionAction); + fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-shell', invocationMessage: 'Running `echo hello`', toolInput: 'echo hello', confirmed: 'not-needed' } as SessionAction); fire({ type: 'session/toolCallComplete', session, turnId, toolCallId: 'tc-shell', result: { success: true, pastTenseMessage: 'Ran `echo hello`', content: [{ type: 'terminal', resource: 'agenthost-terminal:///tc-shell-term' }, { type: 'text', text: 'hello\n' }] }, - } as ISessionAction); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + } as SessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; @@ -1209,13 +1209,13 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); - fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-fail', toolName: 'bash', displayName: 'Bash', _meta: { toolKind: 'terminal', language: 'shellscript' } } as ISessionAction); - fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-fail', invocationMessage: 'Running `bad_cmd`', toolInput: 'bad_cmd', confirmed: 'not-needed' } as ISessionAction); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-fail', toolName: 'bash', displayName: 'Bash', _meta: { toolKind: 'terminal', language: 'shellscript' } } as SessionAction); + fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-fail', invocationMessage: 'Running `bad_cmd`', toolInput: 'bad_cmd', confirmed: 'not-needed' } as SessionAction); fire({ type: 'session/toolCallComplete', session, turnId, toolCallId: 'tc-fail', result: { success: false, pastTenseMessage: '"Bash" failed', content: [{ type: 'terminal', resource: 'agenthost-terminal:///tc-fail-term' }, { type: 'text', text: 'command not found: bad_cmd' }], error: { message: 'command not found: bad_cmd' } }, - } as ISessionAction); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + } as SessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; @@ -1237,13 +1237,13 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); - fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-gen', toolName: 'custom_tool', displayName: 'custom_tool' } as ISessionAction); - fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-gen', invocationMessage: 'Using "custom_tool"', confirmed: 'not-needed' } as ISessionAction); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-gen', toolName: 'custom_tool', displayName: 'custom_tool' } as SessionAction); + fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-gen', invocationMessage: 'Using "custom_tool"', confirmed: 'not-needed' } as SessionAction); fire({ type: 'session/toolCallComplete', session, turnId, toolCallId: 'tc-gen', result: { success: true, pastTenseMessage: 'Used "custom_tool"' }, - } as ISessionAction); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + } as SessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; @@ -1264,13 +1264,13 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); - fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-noargs', toolName: 'bash', displayName: 'Bash', toolKind: 'terminal' } as ISessionAction); - fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-noargs', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as ISessionAction); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-noargs', toolName: 'bash', displayName: 'Bash', toolKind: 'terminal' } as SessionAction); + fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-noargs', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as SessionAction); fire({ type: 'session/toolCallComplete', session, turnId, toolCallId: 'tc-noargs', result: { success: true, pastTenseMessage: 'Ran Bash command' }, - } as ISessionAction); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + } as SessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; @@ -1291,13 +1291,13 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); - fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-view', toolName: 'view', displayName: 'View File' } as ISessionAction); - fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-view', invocationMessage: 'Reading /tmp/test.txt', confirmed: 'not-needed' } as ISessionAction); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-view', toolName: 'view', displayName: 'View File' } as SessionAction); + fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-view', invocationMessage: 'Reading /tmp/test.txt', confirmed: 'not-needed' } as SessionAction); fire({ type: 'session/toolCallComplete', session, turnId, toolCallId: 'tc-view', result: { success: true, pastTenseMessage: 'Read /tmp/test.txt' }, - } as ISessionAction); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + } as SessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; @@ -1336,7 +1336,7 @@ suite('AgentHostChatContribution', () => { }], usage: undefined, }], - } as ISessionState); + } as SessionState); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/tool-hist' }); const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); @@ -1377,7 +1377,7 @@ suite('AgentHostChatContribution', () => { }], usage: undefined, }], - } as ISessionState); + } as SessionState); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/orphan-tool' }); const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); @@ -1408,7 +1408,7 @@ suite('AgentHostChatContribution', () => { }], usage: undefined, }], - } as ISessionState); + } as SessionState); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/generic-tool' }); const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); @@ -1430,7 +1430,7 @@ suite('AgentHostChatContribution', () => { ...createSessionState({ resource: sessionUri.toString(), provider: 'copilot', title: 'Test', status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now() }), lifecycle: SessionLifecycle.Ready, turns: [], - } as ISessionState); + } as SessionState); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/empty-sess' }); const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); @@ -1456,7 +1456,7 @@ suite('AgentHostChatContribution', () => { session, turnId, error: { errorType: 'connection_error', message: 'connection lost' }, - } as ISessionAction, + } as SessionAction, serverSeq: 99, origin: undefined, }); @@ -1587,7 +1587,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -1608,7 +1608,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -1629,7 +1629,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -1650,7 +1650,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -1670,7 +1670,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -1692,7 +1692,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -1709,7 +1709,7 @@ suite('AgentHostChatContribution', () => { const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { message: 'Hello', }); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -1793,7 +1793,7 @@ suite('AgentHostChatContribution', () => { })); const { turnPromise, session, turnId, fire } = await startTurn(handler, agentHostService, chatAgentService, disposables, { agentId: 'workdir-test' }); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); @@ -1815,7 +1815,7 @@ suite('AgentHostChatContribution', () => { const config = { isolation: 'worktree', branch: 'feature/config' }; const { turnPromise, session, turnId, fire } = await startDynamicAgentTurn(chatAgentService, agentHostService, 'config-test', { message: 'Add Agent Host session configuration flow', agentHostSessionConfig: config }); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); @@ -1851,7 +1851,7 @@ suite('AgentHostChatContribution', () => { })); const { turnPromise, session, turnId, fire } = await startTurn(handler, agentHostService, chatAgentService, disposables, { agentId: 'workdir-resolver-test' }); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); @@ -1883,7 +1883,7 @@ suite('AgentHostChatContribution', () => { })); const { turnPromise, session, turnId, fire } = await startTurn(handler, agentHostService, chatAgentService, disposables, { agentId: 'workdir-agenthost-test' }); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); @@ -1939,8 +1939,8 @@ suite('AgentHostChatContribution', () => { agentId: 'connection-test', }); - fire({ type: 'session/delta', session, turnId, content: 'Response' } as ISessionAction); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/delta', session, turnId, content: 'Response' } as SessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; // Turn dispatched via connection.dispatchAction @@ -1953,8 +1953,8 @@ suite('AgentHostChatContribution', () => { suite('reconnection to active turn', () => { - function makeSessionStateWithActiveTurn(sessionUri: string, overrides?: Partial<{ streamingText: string; reasoning: string }>): ISessionState { - const summary: ISessionSummary = { + function makeSessionStateWithActiveTurn(sessionUri: string, overrides?: Partial<{ streamingText: string; reasoning: string }>): SessionState { + const summary: SessionSummary = { resource: sessionUri, provider: 'copilot', title: 'Active Session', @@ -2078,7 +2078,7 @@ suite('AgentHostChatContribution', () => { // Fire a delta action to simulate the server streaming more text agentHostService.fireAction({ - action: { type: 'session/delta', session: sessionUri.toString(), turnId: 'turn-active', partId: 'md-active', content: ' and more' } as ISessionAction, + action: { type: 'session/delta', session: sessionUri.toString(), turnId: 'turn-active', partId: 'md-active', content: ' and more' } as SessionAction, serverSeq: 1, origin: undefined, }); @@ -2107,7 +2107,7 @@ suite('AgentHostChatContribution', () => { // Fire turnComplete to finish the active turn agentHostService.fireAction({ - action: { type: 'session/turnComplete', session: sessionUri.toString(), turnId: 'turn-active' } as ISessionAction, + action: { type: 'session/turnComplete', session: sessionUri.toString(), turnId: 'turn-active' } as SessionAction, serverSeq: 1, origin: undefined, }); @@ -2175,7 +2175,7 @@ suite('AgentHostChatContribution', () => { // Complete the turn so the awaitConfirmation promise and its internal // DisposableStore are cleaned up before test teardown. agentHostService.fireAction({ - action: { type: 'session/turnComplete', session: sessionUri.toString(), turnId: 'turn-active' } as ISessionAction, + action: { type: 'session/turnComplete', session: sessionUri.toString(), turnId: 'turn-active' } as SessionAction, serverSeq: 1, origin: undefined, }); @@ -2257,7 +2257,7 @@ suite('AgentHostChatContribution', () => { const session = action1.session; // Echo + complete the first turn agentHostService.fireAction({ action: dispatch1.action, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: dispatch1.clientSeq } }); - agentHostService.fireAction({ action: { type: 'session/turnComplete', session, turnId: action1.turnId } as ISessionAction, serverSeq: 2, origin: undefined }); + agentHostService.fireAction({ action: { type: 'session/turnComplete', session, turnId: action1.turnId } as SessionAction, serverSeq: 2, origin: undefined }); await turn1Promise; // Now simulate a server-initiated turn (e.g. from a consumed queued message) @@ -2271,7 +2271,7 @@ suite('AgentHostChatContribution', () => { session, turnId: serverTurnId, userMessage: { text: 'queued message text' }, - } as ISessionAction, + } as SessionAction, serverSeq: 3, origin: undefined, // Server-originated — no client origin }); @@ -2308,24 +2308,24 @@ suite('AgentHostChatContribution', () => { const action1 = dispatch1.action as ITurnStartedAction; const session = action1.session; agentHostService.fireAction({ action: dispatch1.action, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: dispatch1.clientSeq } }); - agentHostService.fireAction({ action: { type: 'session/turnComplete', session, turnId: action1.turnId } as ISessionAction, serverSeq: 2, origin: undefined }); + agentHostService.fireAction({ action: { type: 'session/turnComplete', session, turnId: action1.turnId } as SessionAction, serverSeq: 2, origin: undefined }); await turn1Promise; // Server-initiated turn const serverTurnId = 'server-turn-progress'; agentHostService.fireAction({ - action: { type: 'session/turnStarted', session, turnId: serverTurnId, userMessage: { text: 'auto queued' } } as ISessionAction, + action: { type: 'session/turnStarted', session, turnId: serverTurnId, userMessage: { text: 'auto queued' } } as SessionAction, serverSeq: 3, origin: undefined, }); await timeout(10); // Stream a response part + delta agentHostService.fireAction({ - action: { type: 'session/responsePart', session, turnId: serverTurnId, part: { kind: 'markdown', id: 'md-srv', content: 'Hello ' } } as ISessionAction, + action: { type: 'session/responsePart', session, turnId: serverTurnId, part: { kind: 'markdown', id: 'md-srv', content: 'Hello ' } } as SessionAction, serverSeq: 4, origin: undefined, }); agentHostService.fireAction({ - action: { type: 'session/delta', session, turnId: serverTurnId, partId: 'md-srv', content: 'world' } as ISessionAction, + action: { type: 'session/delta', session, turnId: serverTurnId, partId: 'md-srv', content: 'world' } as SessionAction, serverSeq: 5, origin: undefined, }); await timeout(50); @@ -2338,7 +2338,7 @@ suite('AgentHostChatContribution', () => { // Complete the turn agentHostService.fireAction({ - action: { type: 'session/turnComplete', session, turnId: serverTurnId } as ISessionAction, + action: { type: 'session/turnComplete', session, turnId: serverTurnId } as SessionAction, serverSeq: 6, origin: undefined, }); await timeout(10); @@ -2384,7 +2384,7 @@ suite('AgentHostChatContribution', () => { const dispatch = agentHostService.turnActions[0]; const action = dispatch.action as ITurnStartedAction; agentHostService.fireAction({ action: dispatch.action, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: dispatch.clientSeq } }); - agentHostService.fireAction({ action: { type: 'session/turnComplete', session: action.session, turnId: action.turnId } as ISessionAction, serverSeq: 2, origin: undefined }); + agentHostService.fireAction({ action: { type: 'session/turnComplete', session: action.session, turnId: action.turnId } as SessionAction, serverSeq: 2, origin: undefined }); await turnPromise; assert.strictEqual(serverRequestEvents.length, 0, 'Client-dispatched turns should not trigger onDidStartServerRequest'); @@ -2412,42 +2412,42 @@ suite('AgentHostChatContribution', () => { const action1 = dispatch1.action as ITurnStartedAction; const session = action1.session; agentHostService.fireAction({ action: dispatch1.action, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: dispatch1.clientSeq } }); - agentHostService.fireAction({ action: { type: 'session/turnComplete', session, turnId: action1.turnId } as ISessionAction, serverSeq: 2, origin: undefined }); + agentHostService.fireAction({ action: { type: 'session/turnComplete', session, turnId: action1.turnId } as SessionAction, serverSeq: 2, origin: undefined }); await turn1Promise; // Server-initiated turn const serverTurnId = 'server-turn-tool-dedup'; agentHostService.fireAction({ - action: { type: 'session/turnStarted', session, turnId: serverTurnId, userMessage: { text: 'queued' } } as ISessionAction, + action: { type: 'session/turnStarted', session, turnId: serverTurnId, userMessage: { text: 'queued' } } as SessionAction, serverSeq: 3, origin: undefined, }); await timeout(10); // Tool start + ready (auto-confirmed) agentHostService.fireAction({ - action: { type: 'session/toolCallStart', session, turnId: serverTurnId, toolCallId: 'tc-srv-1', toolName: 'bash', displayName: 'Bash' } as ISessionAction, + action: { type: 'session/toolCallStart', session, turnId: serverTurnId, toolCallId: 'tc-srv-1', toolName: 'bash', displayName: 'Bash' } as SessionAction, serverSeq: 4, origin: undefined, }); agentHostService.fireAction({ - action: { type: 'session/toolCallReady', session, turnId: serverTurnId, toolCallId: 'tc-srv-1', invocationMessage: 'Running Bash', confirmed: 'not-needed' } as ISessionAction, + action: { type: 'session/toolCallReady', session, turnId: serverTurnId, toolCallId: 'tc-srv-1', invocationMessage: 'Running Bash', confirmed: 'not-needed' } as SessionAction, serverSeq: 5, origin: undefined, }); await timeout(50); // Tool complete agentHostService.fireAction({ - action: { type: 'session/toolCallComplete', session, turnId: serverTurnId, toolCallId: 'tc-srv-1', result: { success: true, pastTenseMessage: 'Ran Bash' } } as ISessionAction, + action: { type: 'session/toolCallComplete', session, turnId: serverTurnId, toolCallId: 'tc-srv-1', result: { success: true, pastTenseMessage: 'Ran Bash' } } as SessionAction, serverSeq: 6, origin: undefined, }); await timeout(50); // Fire additional state changes that might cause re-processing agentHostService.fireAction({ - action: { type: 'session/responsePart', session, turnId: serverTurnId, part: { kind: 'markdown', id: 'md-after', content: 'Done.' } } as ISessionAction, + action: { type: 'session/responsePart', session, turnId: serverTurnId, part: { kind: 'markdown', id: 'md-after', content: 'Done.' } } as SessionAction, serverSeq: 7, origin: undefined, }); agentHostService.fireAction({ - action: { type: 'session/turnComplete', session, turnId: serverTurnId } as ISessionAction, + action: { type: 'session/turnComplete', session, turnId: serverTurnId } as SessionAction, serverSeq: 8, origin: undefined, }); await timeout(50); @@ -2480,7 +2480,7 @@ suite('AgentHostChatContribution', () => { const action1 = dispatch1.action as ITurnStartedAction; const session = action1.session; agentHostService.fireAction({ action: dispatch1.action, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: dispatch1.clientSeq } }); - agentHostService.fireAction({ action: { type: 'session/turnComplete', session, turnId: action1.turnId } as ISessionAction, serverSeq: 2, origin: undefined }); + agentHostService.fireAction({ action: { type: 'session/turnComplete', session, turnId: action1.turnId } as SessionAction, serverSeq: 2, origin: undefined }); await turn1Promise; // Fire turnStarted followed immediately by a response part. @@ -2490,11 +2490,11 @@ suite('AgentHostChatContribution', () => { // is not missed. const serverTurnId = 'server-turn-md-initial'; agentHostService.fireAction({ - action: { type: 'session/turnStarted', session, turnId: serverTurnId, userMessage: { text: 'queued' } } as ISessionAction, + action: { type: 'session/turnStarted', session, turnId: serverTurnId, userMessage: { text: 'queued' } } as SessionAction, serverSeq: 3, origin: undefined, }); agentHostService.fireAction({ - action: { type: 'session/responsePart', session, turnId: serverTurnId, part: { kind: 'markdown', id: 'md-init', content: 'Initial text' } } as ISessionAction, + action: { type: 'session/responsePart', session, turnId: serverTurnId, part: { kind: 'markdown', id: 'md-init', content: 'Initial text' } } as SessionAction, serverSeq: 4, origin: undefined, }); await timeout(50); @@ -2507,7 +2507,7 @@ suite('AgentHostChatContribution', () => { // Complete the turn agentHostService.fireAction({ - action: { type: 'session/turnComplete', session, turnId: serverTurnId } as ISessionAction, + action: { type: 'session/turnComplete', session, turnId: serverTurnId } as SessionAction, serverSeq: 5, origin: undefined, }); await timeout(10); @@ -2523,7 +2523,7 @@ suite('AgentHostChatContribution', () => { test('dispatches activeClientChanged when a new session is created', async () => { const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); - const customizations = observableValue('customizations', [ + const customizations = observableValue('customizations', [ { uri: 'file:///plugin-a', displayName: 'Plugin A' }, ]); @@ -2539,7 +2539,7 @@ suite('AgentHostChatContribution', () => { })); const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; // The active-client claim is now threaded through createSession @@ -2555,7 +2555,7 @@ suite('AgentHostChatContribution', () => { test('re-dispatches activeClientChanged when customizations observable changes', async () => { const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); - const customizations = observableValue('customizations', []); + const customizations = observableValue('customizations', []); const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { provider: 'copilot' as const, @@ -2570,7 +2570,7 @@ suite('AgentHostChatContribution', () => { // Create a session first const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; agentHostService.dispatchedActions.length = 0; @@ -2584,7 +2584,7 @@ suite('AgentHostChatContribution', () => { d => d.action.type === 'session/activeClientChanged' ); assert.ok(activeClientAction, 'should re-dispatch activeClientChanged on change'); - const ac = activeClientAction!.action as { activeClient: { customizations?: ICustomizationRef[] } }; + const ac = activeClientAction!.action as { activeClient: { customizations?: CustomizationRef[] } }; assert.strictEqual(ac.activeClient.customizations?.length, 1); assert.strictEqual(ac.activeClient.customizations?.[0].uri, 'file:///plugin-b'); }); @@ -2597,8 +2597,8 @@ suite('AgentHostChatContribution', () => { /** * Build a child session state containing a single inner tool call in the running state. */ - function makeChildState(childUri: string, innerToolCallId: string): ISessionState { - const summary: ISessionSummary = { + function makeChildState(childUri: string, innerToolCallId: string): SessionState { + const summary: SessionSummary = { resource: childUri, provider: 'copilot', title: 'Subagent', @@ -2606,7 +2606,7 @@ suite('AgentHostChatContribution', () => { createdAt: Date.now(), modifiedAt: Date.now(), }; - const innerTool: IToolCallState = { + const innerTool: ToolCallState = { toolCallId: innerToolCallId, toolName: 'read_file', displayName: 'Read File', @@ -2614,7 +2614,7 @@ suite('AgentHostChatContribution', () => { invocationMessage: 'Reading file', toolInput: '{}', confirmed: ToolCallConfirmationReason.NotNeeded, - } as IToolCallState; + } as ToolCallState; const activeTurn = createActiveTurn('child-turn-1', { text: 'do work' }); activeTurn.responseParts.push({ kind: ResponsePartKind.ToolCall, toolCall: innerTool }); return { @@ -2641,17 +2641,17 @@ suite('AgentHostChatContribution', () => { type: 'session/toolCallStart', session, turnId, toolCallId: parentToolCallId, toolName: 'task', displayName: 'Task', _meta: { toolKind: 'subagent', subagentDescription: 'do some work', subagentAgentName: 'helper' }, - } as ISessionAction); + } as SessionAction); fire({ type: 'session/toolCallReady', session, turnId, toolCallId: parentToolCallId, invocationMessage: 'Spawning subagent', confirmed: 'not-needed', - } as ISessionAction); + } as SessionAction); // Allow the throttler/observation flow to flush. await timeout(50); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; // Flatten all progress emissions and find tool invocations. @@ -2683,12 +2683,12 @@ suite('AgentHostChatContribution', () => { type: 'session/toolCallStart', session, turnId, toolCallId: parentToolCallId, toolName: 'task', displayName: 'Task', _meta: { toolKind: 'subagent', subagentDescription: 'do work', subagentAgentName: 'helper' }, - } as ISessionAction); + } as SessionAction); fire({ type: 'session/toolCallReady', session, turnId, toolCallId: parentToolCallId, invocationMessage: 'Spawning subagent', confirmed: 'not-needed', - } as ISessionAction); + } as SessionAction); // Allow the subscription to be set up. await timeout(50); @@ -2696,7 +2696,7 @@ suite('AgentHostChatContribution', () => { // NOW fire the child session lifecycle: turnStarted, then a tool call. const childTurnId = 'child-turn-1'; const childToolCallId = 'tc-child-1'; - const fireChild = (action: ISessionAction) => { + const fireChild = (action: SessionAction) => { agentHostService.fireAction({ action, serverSeq: 1000, origin: undefined }); }; fireChild({ @@ -2704,20 +2704,20 @@ suite('AgentHostChatContribution', () => { session: childSessionUri, turnId: childTurnId, userMessage: { text: '' }, - } as ISessionAction); + } as SessionAction); fireChild({ type: 'session/toolCallStart', session: childSessionUri, turnId: childTurnId, toolCallId: childToolCallId, toolName: 'read_file', displayName: 'Read File', - } as ISessionAction); + } as SessionAction); fireChild({ type: 'session/toolCallReady', session: childSessionUri, turnId: childTurnId, toolCallId: childToolCallId, invocationMessage: 'Reading file', confirmed: 'not-needed', - } as ISessionAction); + } as SessionAction); await timeout(50); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; const allParts = collected.flat(); @@ -2750,12 +2750,12 @@ suite('AgentHostChatContribution', () => { type: 'session/toolCallStart', session, turnId, toolCallId: parentToolCallId, toolName: 'task', displayName: 'Task', _meta: { toolKind: 'subagent', subagentDescription: 'Exploring codebase structure' }, - } as ISessionAction); + } as SessionAction); fire({ type: 'session/toolCallReady', session, turnId, toolCallId: parentToolCallId, invocationMessage: 'Spawning subagent', confirmed: 'not-needed', - } as ISessionAction); + } as SessionAction); await timeout(50); @@ -2770,11 +2770,11 @@ suite('AgentHostChatContribution', () => { title: 'Subagent', agentName: 'explore', }], - } as ISessionAction); + } as SessionAction); await timeout(50); - fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; const allParts = collected.flat(); 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 98d568bb1f33a..58add97bfc0d4 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,8 +14,8 @@ 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 IActionEnvelope, type INotification, type ISessionAction, type ITerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import { SessionLifecycle, SessionStatus, createSessionState, StateComponents, type ISessionState, type ISessionSummary, type IRootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { isSessionAction, type ActionEnvelope, type INotification, 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'; import { IChatAgentService } from '../../../common/participants/chatAgents.js'; @@ -275,17 +275,17 @@ suite('AgentHostClientTools', () => { class MockAgentHostConnection extends mock() { declare readonly _serviceBrand: undefined; override readonly clientId = 'test-client'; - private readonly _onDidAction = disposables.add(new Emitter()); + private readonly _onDidAction = disposables.add(new Emitter()); override readonly onDidAction = this._onDidAction.event; private readonly _onDidNotification = disposables.add(new Emitter()); override readonly onDidNotification = this._onDidNotification.event; override readonly onAgentHostExit = Event.None; override readonly onAgentHostStart = Event.None; - private readonly _liveSubscriptions = new Map }>(); - public dispatchedActions: (ISessionAction | ITerminalAction)[] = []; + private readonly _liveSubscriptions = new Map }>(); + public dispatchedActions: (SessionAction | TerminalAction)[] = []; - override dispatch(action: ISessionAction | ITerminalAction): void { + override dispatch(action: SessionAction | TerminalAction): void { this.dispatchedActions.push(action); if (isSessionAction(action) && action.type === 'session/activeClientChanged') { const entry = this._liveSubscriptions.get(action.session); @@ -303,7 +303,7 @@ suite('AgentHostClientTools', () => { } } - override readonly rootState: IAgentSubscription = { + override readonly rootState: IAgentSubscription = { value: undefined, verifiedValue: undefined, onDidChange: Event.None, @@ -314,7 +314,7 @@ suite('AgentHostClientTools', () => { override getSubscription(_kind: StateComponents, resource: URI): IReference> { const resourceStr = resource.toString(); const emitter = disposables.add(new Emitter()); - const summary: ISessionSummary = { + const summary: SessionSummary = { resource: resourceStr, provider: 'copilot', title: 'Test', @@ -322,8 +322,8 @@ suite('AgentHostClientTools', () => { createdAt: Date.now(), modifiedAt: Date.now(), }; - const initialState: ISessionState = { ...createSessionState(summary), lifecycle: SessionLifecycle.Ready }; - const entry = { state: initialState, emitter: emitter as unknown as Emitter }; + const initialState: SessionState = { ...createSessionState(summary), lifecycle: SessionLifecycle.Ready }; + const entry = { state: initialState, emitter: emitter as unknown as Emitter }; this._liveSubscriptions.set(resourceStr, entry); const self = this; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index d6a00b2371ee6..3254c2001dc09 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -7,14 +7,14 @@ import assert from 'assert'; import { autorun } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, type IActiveTurn, type ICompletedToolCall, type IToolCallRunningState, type ITurn, type IToolCallResponsePart, ToolCallCancellationReason } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, type ActiveTurn, type ICompletedToolCall, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IChatToolInvocationSerialized, type IChatMarkdownContent } from '../../../common/chatService/chatService.js'; import { ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; import { turnsToHistory as rawTurnsToHistory, activeTurnToProgress as rawActiveTurnToProgress, toolCallStateToInvocation as rawToolCallStateToInvocation, finalizeToolInvocation as rawFinalizeToolInvocation, updateRunningToolSpecificData as rawUpdateRunningToolSpecificData } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; // ---- Helper factories ------------------------------------------------------- -function createToolCallState(overrides?: Partial): IToolCallRunningState { +function createToolCallState(overrides?: Partial): ToolCallRunningState { return { toolCallId: 'tc-1', toolName: 'test_tool', @@ -40,7 +40,7 @@ function createCompletedToolCall(overrides?: Partial): IComp } as ICompletedToolCall; } -function createTurn(overrides?: Partial): ITurn { +function createTurn(overrides?: Partial): Turn { return { id: 'turn-1', userMessage: { text: 'Hello' }, @@ -87,7 +87,7 @@ suite('stateToProgressAdapter', () => { test('single turn produces request + response pair', () => { const turn = createTurn({ userMessage: { text: 'Do something' }, - responseParts: [{ kind: ResponsePartKind.ToolCall, toolCall: createCompletedToolCall() } as IToolCallResponsePart], + responseParts: [{ kind: ResponsePartKind.ToolCall, toolCall: createCompletedToolCall() } as ToolCallResponsePart], }); const history = turnsToHistory(URI.file('/'), [turn], 'participant-1'); @@ -137,7 +137,7 @@ suite('stateToProgressAdapter', () => { ], success: true, }) - } as IToolCallResponsePart], + } as ToolCallResponsePart], }); const history = turnsToHistory(URI.file('/'), [turn], 'p'); @@ -165,7 +165,7 @@ suite('stateToProgressAdapter', () => { ], success: true, }) - } as IToolCallResponsePart], + } as ToolCallResponsePart], }); const history = turnsToHistory(URI.file('/'), [turn], 'p'); @@ -196,7 +196,7 @@ suite('stateToProgressAdapter', () => { content: [{ type: ToolResultContentType.Text, text: 'Result text' }], success: true, }) - } as IToolCallResponsePart], + } as ToolCallResponsePart], }); const history = turnsToHistory(URI.file('/'), [turn], 'p'); @@ -318,7 +318,7 @@ suite('stateToProgressAdapter', () => { ], success: false, }) - } as IToolCallResponsePart], + } as ToolCallResponsePart], }); const history = turnsToHistory(URI.file('/'), [turn], 'p'); @@ -596,7 +596,7 @@ suite('stateToProgressAdapter', () => { suite('activeTurnToProgress', () => { - function createActiveTurnState(responseParts?: IActiveTurn['responseParts']): IActiveTurn { + function createActiveTurnState(responseParts?: ActiveTurn['responseParts']): ActiveTurn { return { id: 'turn-active', userMessage: { text: 'Do things' }, @@ -650,7 +650,7 @@ suite('stateToProgressAdapter', () => { confirmed: ToolCallConfirmationReason.NotNeeded, success: true, pastTenseMessage: 'Ran test tool', - } as IToolCallResponsePart['toolCall'], + } as ToolCallResponsePart['toolCall'], }, ]), undefined); assert.strictEqual(result.length, 1); @@ -738,7 +738,7 @@ suite('stateToProgressAdapter', () => { }); const turn = createTurn({ - responseParts: [{ kind: ResponsePartKind.ToolCall, toolCall: tc } as IToolCallResponsePart], + responseParts: [{ kind: ResponsePartKind.ToolCall, toolCall: tc } as ToolCallResponsePart], }); const history = turnsToHistory(URI.file('/'), [turn], 'p'); @@ -767,7 +767,7 @@ suite('stateToProgressAdapter', () => { }); const turn = createTurn({ - responseParts: [{ kind: ResponsePartKind.ToolCall, toolCall: tc } as IToolCallResponsePart], + responseParts: [{ kind: ResponsePartKind.ToolCall, toolCall: tc } as ToolCallResponsePart], }); const history = turnsToHistory(URI.file('/'), [turn], 'p'); @@ -845,7 +845,7 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(invocation.toolSpecificData?.kind, 'subagent'); // Simulate subagent content arriving via SessionToolCallContentChanged - const runningTc: IToolCallRunningState = { + const runningTc: ToolCallRunningState = { ...tc, status: ToolCallStatus.Running, _meta: { toolKind: 'subagent', subagentDescription: 'Find related files' }, @@ -884,7 +884,7 @@ suite('stateToProgressAdapter', () => { const invocation = toolCallStateToInvocation(tc); const originalData = invocation.toolSpecificData; - const runningTc: IToolCallRunningState = { + const runningTc: ToolCallRunningState = { ...tc, status: ToolCallStatus.Running, }; diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts index 101e3235aeabe..4ba131c89506a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts @@ -37,7 +37,6 @@ suite('PromptHeaderAutocompletion', () => { setup(async () => { const testConfigService = new TestConfigurationService(); testConfigService.setUserConfiguration(ChatConfiguration.ExtensionToolsEnabled, true); - testConfigService.setUserConfiguration('chat.useCustomAgentHooks', true); instaService = workbenchInstantiationService({ contextKeyService: () => disposables.add(new ContextKeyService(testConfigService)), configurationService: () => testConfigService diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts index ab2a3b4067c0d..f90f6d4570794 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts @@ -37,7 +37,6 @@ suite('PromptHoverProvider', () => { setup(async () => { const testConfigService = new TestConfigurationService(); testConfigService.setUserConfiguration(ChatConfiguration.ExtensionToolsEnabled, true); - testConfigService.setUserConfiguration('chat.useCustomAgentHooks', true); instaService = workbenchInstantiationService({ contextKeyService: () => disposables.add(new ContextKeyService(testConfigService)), configurationService: () => testConfigService 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 dbcaa1e948c4c..d561511eae027 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 @@ -28,7 +28,6 @@ import { PromptFileParser } from '../../../../common/promptSyntax/promptFilePars import { ICustomAgent, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; import { MockChatModeService } from '../../../common/mockChatModeService.js'; import { MockPromptsService } from '../../../common/promptSyntax/service/mockPromptsService.js'; -import { PromptsConfig } from '../../../../common/promptSyntax/config/config.js'; suite('PromptValidator', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -43,7 +42,6 @@ suite('PromptValidator', () => { testConfigService = new TestConfigurationService(); testConfigService.setUserConfiguration(ChatConfiguration.ExtensionToolsEnabled, true); - testConfigService.setUserConfiguration(PromptsConfig.USE_CUSTOM_AGENT_HOOKS, true); instaService = workbenchInstantiationService({ contextKeyService: () => disposables.add(new ContextKeyService(testConfigService)), configurationService: () => testConfigService diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index 2bf55665ee6b4..2ee12045a2726 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts @@ -19,6 +19,7 @@ import { TestConfigurationService } from '../../../../../../platform/configurati import { ContextKeyService } from '../../../../../../platform/contextkey/browser/contextKeyService.js'; import { ContextKeyEqualsExpr, ContextKeyExpr, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; +import { ConfirmationOptionKind } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { LanguageModelToolsService } from '../../../browser/tools/languageModelToolsService.js'; @@ -510,7 +511,10 @@ suite('LanguageModelToolsService', () => { confirmationMessages: { title: 'Confirm', message: 'Pick an option', - customButtons: ['Option A', 'Option B'], + customOptions: [ + { id: 'Option A', label: 'Option A', kind: ConfirmationOptionKind.Approve }, + { id: 'Option B', label: 'Option B', kind: ConfirmationOptionKind.Deny }, + ], allowAutoConfirm: false, } }), @@ -564,13 +568,16 @@ suite('LanguageModelToolsService', () => { assert.strictEqual(result.content[0].value, 'ok'); }); - test('confirmationMessages with customButtons disables allowAutoConfirm', async () => { + test('confirmationMessages with customOptions disables allowAutoConfirm', async () => { const tool = registerToolForTest(service, store, 'testToolCustomBtnNoAuto', { prepareToolInvocation: async () => ({ confirmationMessages: { title: 'Confirm', message: 'Choose', - customButtons: ['Yes', 'No'], + customOptions: [ + { id: 'Yes', label: 'Yes', kind: ConfirmationOptionKind.Approve }, + { id: 'No', label: 'No', kind: ConfirmationOptionKind.Deny }, + ], allowAutoConfirm: false, } }), @@ -586,7 +593,7 @@ suite('LanguageModelToolsService', () => { const promise = service.invokeTool(dto, async () => 0, CancellationToken.None); const published = await waitForPublishedInvocation(capture); assert.ok(published, 'expected ChatToolInvocation to be published'); - assert.deepStrictEqual(published.confirmationMessages?.customButtons, ['Yes', 'No']); + assert.deepStrictEqual(published.confirmationMessages?.customOptions?.map(o => o.label), ['Yes', 'No']); IChatToolInvocation.confirmWith(published, { type: ToolConfirmKind.UserAction, selectedButton: 'Yes' }); await promise; diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/config.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/config.test.ts index a6f8c4892063d..0e0ede10e8c6a 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/config.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/config.test.ts @@ -428,7 +428,7 @@ suite('PromptsConfig', () => { test('empty object returns default skill folders', () => { assert.deepStrictEqual( getPaths(PromptsConfig.promptSourceFolders(createMock({}), PromptsType.skill)), - ['.github/skills', '.agents/skills', '.claude/skills', '~/.copilot/skills', '~/.agents/skills', '~/.claude/skills'], + ['.agents/skills', '.github/skills', '.claude/skills', '~/.agents/skills', '~/.copilot/skills', '~/.claude/skills'], 'Must return default skill folders.', ); }); @@ -440,11 +440,11 @@ suite('PromptsConfig', () => { './local/skills': true, }), PromptsType.skill)), [ - '.github/skills', '.agents/skills', + '.github/skills', '.claude/skills', - '~/.copilot/skills', '~/.agents/skills', + '~/.copilot/skills', '~/.claude/skills', '/custom/skills', './local/skills', @@ -462,8 +462,8 @@ suite('PromptsConfig', () => { [ '.agents/skills', '.claude/skills', - '~/.copilot/skills', '~/.agents/skills', + '~/.copilot/skills', '~/.claude/skills', '/custom/skills', ], @@ -499,11 +499,11 @@ suite('PromptsConfig', () => { '\n': true, }), PromptsType.skill)), [ - '.github/skills', '.agents/skills', + '.github/skills', '.claude/skills', - '~/.copilot/skills', '~/.agents/skills', + '~/.copilot/skills', '~/.claude/skills', '/valid/skills', './another/valid', @@ -524,11 +524,11 @@ suite('PromptsConfig', () => { '/extra/skills': true, }), PromptsType.skill)), [ - '.github/skills', '.agents/skills', + '.github/skills', '.claude/skills', - '~/.copilot/skills', '~/.agents/skills', + '~/.copilot/skills', '~/.claude/skills', '/extra/skills', ], diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts index 1f1306918de89..deb5e1dc13916 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts @@ -23,7 +23,7 @@ import { IFileMatch, IFileQuery, ISearchService } from '../../../../../../servic import { IUserDataProfileService } from '../../../../../../services/userDataProfile/common/userDataProfile.js'; import { IPathService } from '../../../../../../services/path/common/pathService.js'; import { PromptsConfig } from '../../../../common/promptSyntax/config/config.js'; -import { PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; +import { getSourceDescription, PromptFileSource, PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; import { hasGlobPattern, isValidGlob, isValidPromptFolderPath, PromptFilesLocator } from '../../../../common/promptSyntax/utils/promptFilesLocator.js'; import { mockFiles } from '../testUtils/mockFilesystem.js'; import { mockService } from './mock.js'; @@ -2283,11 +2283,11 @@ suite('PromptFilesLocator', () => { folders, [ // defaults - '/Users/legomushroom/repos/vscode/.github/skills', '/Users/legomushroom/repos/vscode/.agents/skills', + '/Users/legomushroom/repos/vscode/.github/skills', '/Users/legomushroom/repos/vscode/.claude/skills', - '/Users/legomushroom/.copilot/skills', '/Users/legomushroom/.agents/skills', + '/Users/legomushroom/.copilot/skills', '/Users/legomushroom/.claude/skills', // custom '/Users/legomushroom/repos/vscode/custom-skills', @@ -2822,6 +2822,82 @@ suite('PromptFilesLocator', () => { ); }); }); + suite('getHookSourceFolders', () => { + testT('returns source metadata for hook folders', async () => { + configValues[PromptsConfig.HOOKS_LOCATION_KEY] = { + '.github/hooks': true, + '~/.copilot/hooks': true, + // disable Claude paths (which are filtered out anyway) + '.claude/settings.json': false, + '.claude/settings.local.json': false, + '~/.claude/settings.json': false, + }; + setWorkspaceFolders(['/Users/legomushroom/repos/vscode']); + await mockFiles(fileService, []); + const locator = instantiationService.createInstance(PromptFilesLocator); + + const folders = await locator.getHookSourceFolders(); + + assert.deepStrictEqual( + folders.map(f => ({ path: f.uri.path, source: f.source, storage: f.storage })), + [ + { path: '/Users/legomushroom/repos/vscode/.github/hooks', source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, + { path: '/Users/legomushroom/.copilot/hooks', source: PromptFileSource.CopilotPersonal, storage: PromptsStorage.user }, + ], + ); + }); + + testT('excludes Claude paths', async () => { + configValues[PromptsConfig.HOOKS_LOCATION_KEY] = { + '.github/hooks': true, + '.claude/settings.json': true, + '.claude/settings.local.json': true, + '~/.claude/settings.json': true, + '~/.copilot/hooks': true, + }; + setWorkspaceFolders(['/Users/legomushroom/repos/vscode']); + await mockFiles(fileService, []); + const locator = instantiationService.createInstance(PromptFilesLocator); + + const folders = await locator.getHookSourceFolders(); + + // Claude paths should be filtered out + const paths = folders.map(f => f.uri.path); + assert.ok(!paths.some(p => p.includes('.claude')), 'Claude paths must be excluded'); + assert.deepStrictEqual(paths, [ + '/Users/legomushroom/repos/vscode/.github/hooks', + '/Users/legomushroom/.copilot/hooks', + ]); + }); + }); + + suite('getSourceDescription', () => { + test('returns descriptions for all known folder sources', () => { + const folderSources: PromptFileSource[] = [ + PromptFileSource.AgentsWorkspace, + PromptFileSource.AgentsPersonal, + PromptFileSource.GitHubWorkspace, + PromptFileSource.CopilotPersonal, + PromptFileSource.ClaudeWorkspace, + PromptFileSource.ClaudeWorkspaceLocal, + PromptFileSource.ClaudePersonal, + PromptFileSource.UserData, + PromptFileSource.ConfigWorkspace, + PromptFileSource.ConfigPersonal, + ]; + + for (const source of folderSources) { + const description = getSourceDescription(source); + assert.ok(typeof description === 'string' && description.length > 0, `Expected a description for ${source}`); + } + }); + + test('returns undefined for extension/plugin sources', () => { + assert.strictEqual(getSourceDescription(PromptFileSource.ExtensionContribution), undefined); + assert.strictEqual(getSourceDescription(PromptFileSource.ExtensionAPI), undefined); + assert.strictEqual(getSourceDescription(PromptFileSource.Plugin), undefined); + }); + }); }); function assertOutcome(actual: readonly URI[], expected: string[], message: string) { diff --git a/src/vs/workbench/contrib/extensions/electron-browser/devtoolsExtensionHost.contribution.ts b/src/vs/workbench/contrib/extensions/electron-browser/devtoolsExtensionHost.contribution.ts new file mode 100644 index 0000000000000..6d5f07c920f66 --- /dev/null +++ b/src/vs/workbench/contrib/extensions/electron-browser/devtoolsExtensionHost.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 { registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { DebugExtensionHostInDevToolsAction } from './debugExtensionHostAction.js'; + +registerAction2(DebugExtensionHostInDevToolsAction); diff --git a/src/vs/workbench/contrib/extensions/electron-browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/electron-browser/extensions.contribution.ts index a513f824372d9..393014254b6b1 100644 --- a/src/vs/workbench/contrib/extensions/electron-browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/electron-browser/extensions.contribution.ts @@ -19,7 +19,7 @@ import { EditorExtensions, IEditorFactoryRegistry, IEditorSerializer } from '../ import { EditorInput } from '../../../common/editor/editorInput.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; import { RuntimeExtensionsInput } from '../common/runtimeExtensionsInput.js'; -import { DebugExtensionHostInNewWindowAction, DebugExtensionsContribution, DebugExtensionHostInDevToolsAction, DebugRendererInNewWindowAction, DebugExtensionHostAndRendererAction } from './debugExtensionHostAction.js'; +import { DebugExtensionHostInNewWindowAction, DebugExtensionsContribution, DebugRendererInNewWindowAction, DebugExtensionHostAndRendererAction } from './debugExtensionHostAction.js'; import { ExtensionHostProfileService } from './extensionProfileService.js'; import { CleanUpExtensionsFolderAction, OpenExtensionsFolderAction } from './extensionsActions.js'; import { ExtensionsAutoProfiler } from './extensionsAutoProfiler.js'; @@ -83,4 +83,3 @@ registerAction2(StartExtensionHostProfileAction); registerAction2(StopExtensionHostProfileAction); registerAction2(SaveExtensionHostProfileAction); registerAction2(OpenExtensionHostProfileACtion); -registerAction2(DebugExtensionHostInDevToolsAction); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts index 543dda380e74b..c0d5bc398c465 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts @@ -481,6 +481,8 @@ export class InlineChatController implements IEditorContribution { if (!response?.isInProgress.read(r)) { + this.#zone.rawValue?.status.set(response?.result?.details ?? '', undefined); + if (response?.result?.errorDetails) { // ERROR case this.#zone.rawValue?.widget.updateInfo(`$(error) ${response.result.errorDetails.message}`); @@ -502,6 +504,7 @@ export class InlineChatController implements IEditorContribution { } else { this.#zone.rawValue?.widget.domNode.classList.toggle('request-in-progress', true); + this.#zone.rawValue?.status.set('', undefined); let placeholder = response.request?.message.text; const lastProgress = lastResponseProgressObs.read(r); if (lastProgress) { diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts index 261f08d81d0f3..a0f9b20182a95 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts @@ -6,11 +6,13 @@ import { addDisposableListener, Dimension, $ } from '../../../../base/browser/do import * as aria from '../../../../base/browser/ui/aria/aria.js'; import { renderMarkdown, renderAsPlaintext } from '../../../../base/browser/markdownRenderer.js'; import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { ActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { ActionRunner, IAction } from '../../../../base/common/actions.js'; import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun } from '../../../../base/common/observable.js'; +import { autorun, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; +import { Event } from '../../../../base/common/event.js'; import { ScrollbarVisibility } from '../../../../base/common/scrollable.js'; import { assertType } from '../../../../base/common/types.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; @@ -21,17 +23,46 @@ import { Range } from '../../../../editor/common/core/range.js'; import { ScrollType } from '../../../../editor/common/editorCommon.js'; import { IOptions, ZoneWidget } from '../../../../editor/contrib/zoneWidget/browser/zoneWidget.js'; import { localize } from '../../../../nls.js'; -import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; -import { MenuId } from '../../../../platform/actions/common/actions.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { IChatWidgetViewOptions } from '../../chat/browser/chat.js'; import { IChatWidgetLocationOptions } from '../../chat/browser/widget/chatWidget.js'; import { ChatMode } from '../../chat/common/chatModes.js'; import { INotebookEditor } from '../../notebook/browser/notebookBrowser.js'; import { ACTION_REGENERATE_RESPONSE, ACTION_REPORT_ISSUE, ACTION_TOGGLE_DIFF, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, MENU_INLINE_CHAT_SIDE, MENU_INLINE_CHAT_WIDGET_SECONDARY, MENU_INLINE_CHAT_WIDGET_STATUS } from '../common/inlineChat.js'; import { EditorBasedInlineChatWidget } from './inlineChatWidget.js'; +import { ChatAgentLocation } from '../../chat/common/constants.js'; +import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; + +// a "creative" way of adding custom UI into the chat input part +// without knowing/modifying its dom-structure +class StatusPlaceholder extends Action2 { + + static readonly Id = 'inlineChatWidget.statusPlaceholder'; + static readonly CtxHasStatus = new RawContextKey('inlineChatHasStatus', false); + + constructor() { + super({ + id: StatusPlaceholder.Id, + title: '', + precondition: ContextKeyExpr.false(), + menu: { + id: MenuId.ChatInput, + when: ContextKeyExpr.and(ContextKeyExpr.equals(ChatContextKeys.location.key, ChatAgentLocation.EditorInline), StatusPlaceholder.CtxHasStatus), + group: 'navigation', + order: Number.MAX_SAFE_INTEGER + } + }); + } + + run() { } +} + +registerAction2(StatusPlaceholder); export class InlineChatZoneWidget extends ZoneWidget { @@ -50,7 +81,10 @@ export class InlineChatZoneWidget extends ZoneWidget { readonly widget: EditorBasedInlineChatWidget; + readonly status = observableValue(this, ''); + readonly #ctxCursorPosition: IContextKey<'above' | 'below' | ''>; + readonly #ctxHasStatus: IContextKey; #dimension?: Dimension; private notebookEditor?: INotebookEditor; @@ -70,6 +104,7 @@ export class InlineChatZoneWidget extends ZoneWidget { /** @deprecated should go away with inline2 */ clearDelegate: () => Promise, @IInstantiationService instaService: IInstantiationService, + @IActionViewItemService actionViewItemService: IActionViewItemService, @ILogService logService: ILogService, @IContextKeyService contextKeyService: IContextKeyService, ) { @@ -100,11 +135,33 @@ export class InlineChatZoneWidget extends ZoneWidget { this._disposables.add(this.#terminationStore); this.#ctxCursorPosition = CTX_INLINE_CHAT_OUTER_CURSOR_POSITION.bindTo(contextKeyService); + this.#ctxHasStatus = StatusPlaceholder.CtxHasStatus.bindTo(contextKeyService); this._disposables.add(toDisposable(() => { this.#ctxCursorPosition.reset(); + this.#ctxHasStatus.reset(); + })); + + this._disposables.add(autorun(r => { + this.#ctxHasStatus.set(!!this.status.read(r)); })); + this._disposables.add(actionViewItemService.register(MenuId.ChatInput, StatusPlaceholder.Id, (action, options) => { + const that = this; + const item = new class extends ActionViewItem { + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('status-placeholder'); + this._store.add(autorun(r => { + const value = that.status.read(r); + this.action.label = value ?? ''; + this.updateLabel(); + })); + } + }(undefined, action, { ...options, icon: false, label: true }); + return item; + }, Event.fromObservable(this.status, this._disposables))); + this.widget = instaService.createInstance(EditorBasedInlineChatWidget, location, this.editor, { statusMenuId: { menu: MENU_INLINE_CHAT_WIDGET_STATUS, diff --git a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css index 8a737f123866f..14b6a8553762c 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css +++ b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css @@ -385,3 +385,22 @@ font-size: 12px; white-space: nowrap; } + +/* Status Placeholder */ +.monaco-workbench .zone-widget.inline-chat-widget .status-placeholder { + margin-left: auto; + opacity: 0; + pointer-events: none; + transition: opacity 0.1s ease-in-out; +} + +.monaco-workbench .zone-widget.inline-chat-widget .status-placeholder .action-label { + color: var(--vscode-descriptionForeground); + font-size: 11px; + cursor: default; +} + +.monaco-workbench .zone-widget.inline-chat-widget:hover .status-placeholder, +.monaco-workbench .zone-widget.inline-chat-widget:focus-within .status-placeholder { + opacity: 1; +} diff --git a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts index 4800795f75d5d..d71d63718fdf3 100644 --- a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts +++ b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts @@ -67,7 +67,7 @@ Registry.as(Extensions.Configuration).registerConfigurat }, [InlineChatConfigKeys.RenderMode]: { description: localize('renderMode', "Controls how inline chat is rendered."), - default: 'hover', + default: 'zone', type: 'string', enum: ['zone', 'hover'], enumDescriptions: [ @@ -77,7 +77,7 @@ Registry.as(Extensions.Configuration).registerConfigurat experiment: { mode: 'auto' }, - tags: ['experimental'] + tags: ['experimental'], }, [InlineChatConfigKeys.FixDiagnostics]: { description: localize('fixDiagnostics', "Controls whether the Fix action is shown for diagnostics in the editor."), diff --git a/src/vs/workbench/contrib/notebook/test/browser/notebookKernelHistory.test.ts b/src/vs/workbench/contrib/notebook/test/browser/notebookKernelHistory.test.ts index f2ba9495de9fc..8ea64c0b8b32f 100644 --- a/src/vs/workbench/contrib/notebook/test/browser/notebookKernelHistory.test.ts +++ b/src/vs/workbench/contrib/notebook/test/browser/notebookKernelHistory.test.ts @@ -18,7 +18,7 @@ import { NotebookTextModel } from '../../common/model/notebookTextModel.js'; import { PLAINTEXT_LANGUAGE_ID } from '../../../../../editor/common/languages/modesRegistry.js'; import { IMenu, IMenuService } from '../../../../../platform/actions/common/actions.js'; import { NotebookKernelHistoryService } from '../../browser/services/notebookKernelHistoryServiceImpl.js'; -import { IApplicationStorageValueChangeEvent, IProfileStorageValueChangeEvent, IStorageService, IStorageValueChangeEvent, IWillSaveStateEvent, IWorkspaceStorageValueChangeEvent, StorageScope } from '../../../../../platform/storage/common/storage.js'; +import { IApplicationSharedStorageValueChangeEvent, IApplicationStorageValueChangeEvent, IProfileStorageValueChangeEvent, IStorageService, IStorageValueChangeEvent, IWillSaveStateEvent, IWorkspaceStorageValueChangeEvent, StorageScope } from '../../../../../platform/storage/common/storage.js'; import { INotebookLoggingService } from '../../common/notebookLoggingService.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; @@ -77,6 +77,7 @@ suite('NotebookKernelHistoryService', () => { override onDidChangeValue(scope: StorageScope.WORKSPACE, key: string | undefined, disposable: DisposableStore): Event; override onDidChangeValue(scope: StorageScope.PROFILE, key: string | undefined, disposable: DisposableStore): Event; override onDidChangeValue(scope: StorageScope.APPLICATION, key: string | undefined, disposable: DisposableStore): Event; + override onDidChangeValue(scope: StorageScope.APPLICATION_SHARED, key: string | undefined, disposable: DisposableStore): Event; override onDidChangeValue(scope: StorageScope, key: string | undefined, disposable: DisposableStore): Event { return Event.None; } @@ -132,6 +133,7 @@ suite('NotebookKernelHistoryService', () => { override onDidChangeValue(scope: StorageScope.WORKSPACE, key: string | undefined, disposable: DisposableStore): Event; override onDidChangeValue(scope: StorageScope.PROFILE, key: string | undefined, disposable: DisposableStore): Event; override onDidChangeValue(scope: StorageScope.APPLICATION, key: string | undefined, disposable: DisposableStore): Event; + override onDidChangeValue(scope: StorageScope.APPLICATION_SHARED, key: string | undefined, disposable: DisposableStore): Event; override onDidChangeValue(scope: StorageScope, key: string | undefined, disposable: DisposableStore): Event { return Event.None; } diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts b/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts index 08b55272ecd81..30c9e0cee5c3f 100644 --- a/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts +++ b/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts @@ -10,8 +10,8 @@ import { URI } from '../../../../base/common/uri.js'; import { IProcessPropertyMap, ITerminalChildProcess, ITerminalLaunchError, ITerminalLaunchResult, ProcessPropertyType } from '../../../../platform/terminal/common/terminal.js'; import { IAgentConnection } from '../../../../platform/agentHost/common/agentService.js'; import { AGENT_HOST_SCHEME, fromAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js'; -import { ActionType, IActionEnvelope } from '../../../../platform/agentHost/common/state/sessionActions.js'; -import { TerminalClaimKind, type ITerminalContentPart, type ITerminalState } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import { ActionType, ActionEnvelope } from '../../../../platform/agentHost/common/state/sessionActions.js'; +import { TerminalClaimKind, type TerminalContentPart, type TerminalState } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { IAgentSubscription } from '../../../../platform/agentHost/common/state/agentSubscription.js'; import { StateComponents } from '../../../../platform/agentHost/common/state/sessionState.js'; import { BasePty } from '../common/basePty.js'; @@ -95,7 +95,7 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { private readonly _startBarrier = new Barrier(); private readonly _subscriptionDisposables = this._register(new DisposableStore()); - private _subscriptionRef: IReference> | undefined; + private _subscriptionRef: IReference> | undefined; private _initialCwd = ''; private readonly _onCommandExecuted = this._register(new Emitter()); @@ -157,7 +157,7 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { }); } - const state = subscription.value as ITerminalState; + const state = subscription.value as TerminalState; // 4. Replay any existing content from the snapshot if (state.supportsCommandDetection) { @@ -189,7 +189,7 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { } } - private _handleAction(envelope: IActionEnvelope): void { + private _handleAction(envelope: ActionEnvelope): void { const action = envelope.action; switch (action.type) { case ActionType.TerminalData: @@ -254,7 +254,7 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { * Emits command lifecycle events for command parts so that consumers * (e.g. {@link AhpTerminalCommandSource}) can reconstruct command history. */ - private _replayContent(content: ITerminalContentPart[]): void { + private _replayContent(content: TerminalContentPart[]): void { for (const part of content) { if (part.type === 'unclassified') { if (part.value) { diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index df4d5a01c8eef..31cdf8d3f5ec3 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -632,6 +632,13 @@ export interface ITerminalConfigurationService { setPanelContainer(panelContainer: HTMLElement): void; configFontIsMonospace(): boolean; getFont(w: Window, xtermCore?: IXtermCore, excludeDimensions?: boolean): ITerminalFont; + + /** + * Whether a particular command should skip the shell and go to be handled like a regular + * keybinding instead. + * @param commandId The command ID to check. + */ + shouldCommandSkipShell(commandId: string): boolean; } export class TerminalLinkQuickPickEvent extends MouseEvent { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalConfigurationService.ts b/src/vs/workbench/contrib/terminal/browser/terminalConfigurationService.ts index 71dd223725432..385e1b055d110 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalConfigurationService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalConfigurationService.ts @@ -10,7 +10,7 @@ import { EDITOR_FONT_DEFAULTS } from '../../../../editor/common/config/fontInfo. import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ITerminalConfigurationService, LinuxDistro } from './terminal.js'; import type { IXtermCore } from './xterm-private.js'; -import { DEFAULT_BOLD_FONT_WEIGHT, DEFAULT_FONT_WEIGHT, DEFAULT_LETTER_SPACING, DEFAULT_LINE_HEIGHT, FontWeight, ITerminalConfiguration, MAXIMUM_FONT_WEIGHT, MINIMUM_FONT_WEIGHT, MINIMUM_LETTER_SPACING, TERMINAL_CONFIG_SECTION, type ITerminalFont } from '../common/terminal.js'; +import { DEFAULT_BOLD_FONT_WEIGHT, DEFAULT_COMMANDS_TO_SKIP_SHELL, DEFAULT_FONT_WEIGHT, DEFAULT_LETTER_SPACING, DEFAULT_LINE_HEIGHT, FontWeight, ITerminalConfiguration, MAXIMUM_FONT_WEIGHT, MINIMUM_FONT_WEIGHT, MINIMUM_LETTER_SPACING, TERMINAL_CONFIG_SECTION, type ITerminalFont } from '../common/terminal.js'; import { isMacintosh } from '../../../../base/common/platform.js'; import { TerminalLocation, TerminalLocationConfigValue } from '../../../../platform/terminal/common/terminal.js'; import { isString } from '../../../../base/common/types.js'; @@ -22,6 +22,7 @@ export class TerminalConfigurationService extends Disposable implements ITermina declare _serviceBrand: undefined; protected _fontMetrics: TerminalFontMetrics; + private _skipTerminalCommands: ReadonlySet = new Set(DEFAULT_COMMANDS_TO_SKIP_SHELL); protected _config!: Readonly; get config() { return this._config; } @@ -53,12 +54,24 @@ export class TerminalConfigurationService extends Disposable implements ITermina setPanelContainer(panelContainer: HTMLElement): void { return this._fontMetrics.setPanelContainer(panelContainer); } configFontIsMonospace(): boolean { return this._fontMetrics.configFontIsMonospace(); } getFont(w: Window, xtermCore?: IXtermCore, excludeDimensions?: boolean): ITerminalFont { return this._fontMetrics.getFont(w, xtermCore, excludeDimensions); } + shouldCommandSkipShell(commandId: string): boolean { return this._skipTerminalCommands.has(commandId); } private _updateConfig(): void { const configValues = { ...this._configurationService.getValue(TERMINAL_CONFIG_SECTION) }; configValues.fontWeight = this._normalizeFontWeight(configValues.fontWeight, DEFAULT_FONT_WEIGHT); configValues.fontWeightBold = this._normalizeFontWeight(configValues.fontWeightBold, DEFAULT_BOLD_FONT_WEIGHT); this._config = configValues; + const skipTerminalCommands = new Set(DEFAULT_COMMANDS_TO_SKIP_SHELL); + const commandsToSkipShell = configValues.commandsToSkipShell ?? []; + for (let i = 0; i < commandsToSkipShell.length; i++) { + const command = commandsToSkipShell[i]; + if (command[0] === '-') { + skipTerminalCommands.delete(command.slice(1)); + continue; + } + skipTerminalCommands.add(command); + } + this._skipTerminalCommands = skipTerminalCommands; this._onConfigChanged.fire(); } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 50a4c0f49178e..3549954dd1442 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -69,7 +69,7 @@ import { TerminalWidgetManager } from './widgets/widgetManager.js'; import { LineDataEventAddon } from './xterm/lineDataEventAddon.js'; import { XtermTerminal, getXtermScaledDimensions } from './xterm/xtermTerminal.js'; import { IEnvironmentVariableInfo } from '../common/environmentVariable.js'; -import { DEFAULT_COMMANDS_TO_SKIP_SHELL, ITerminalProcessManager, ITerminalProfileResolverService, ProcessState, TERMINAL_VIEW_ID, TerminalCommandId } from '../common/terminal.js'; +import { ITerminalProcessManager, ITerminalProfileResolverService, ProcessState, TERMINAL_VIEW_ID, TerminalCommandId } from '../common/terminal.js'; import { TERMINAL_BACKGROUND_COLOR } from '../common/terminalColorRegistry.js'; import { TerminalContextKeys } from '../common/terminalContextKey.js'; import { getUriLabelForShell, getShellIntegrationTimeout, getWorkspaceForTerminal, preparePathForShell } from '../common/terminalEnvironment.js'; @@ -162,7 +162,6 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { private _hadFocusOnExit: boolean; private _exitCode: number | undefined; private _exitReason: TerminalExitReason | undefined; - private _skipTerminalCommands: string[]; private _shellType: TerminalShellType | undefined; private _agentShellTypeFromSequence: GeneralShellType | undefined; private _title: string = ''; @@ -413,7 +412,6 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { this._widgetManager = this._register(instantiationService.createInstance(TerminalWidgetManager)); - this._skipTerminalCommands = []; this._isExiting = false; this._hadFocusOnExit = false; this._isVisible = false; @@ -1149,7 +1147,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { // keyboard protocol, xterm.js encodes Meta-modified keys as CSI u sequences and // consumes them via preventDefault. The (non-kitty) traditional xterm.js handler already skips // Meta keys so they bubble up naturally, but the kitty handler does not. - if (!this._terminalConfigurationService.config.sendKeybindingsToShell && resolveResult.kind === ResultKind.KbFound && resolveResult.commandId && (event.metaKey || this._skipTerminalCommands.some(k => k === resolveResult.commandId))) { + if (!this._terminalConfigurationService.config.sendKeybindingsToShell && resolveResult.kind === ResultKind.KbFound && resolveResult.commandId && (event.metaKey || this._terminalConfigurationService.shouldCommandSkipShell(resolveResult.commandId))) { event.preventDefault(); return false; } @@ -1947,7 +1945,6 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } updateConfig(): void { - this._setCommandsToSkipShell(this._terminalConfigurationService.config.commandsToSkipShell); this._refreshEnvironmentVariableInfoWidgetState(this._processManager.environmentVariableInfo); } @@ -1959,13 +1956,6 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { this.xterm!.raw.options.screenReaderMode = this._accessibilityService.isScreenReaderOptimized(); } - private _setCommandsToSkipShell(commands: string[]): void { - const excludeCommands = commands.filter(command => command[0] === '-').map(command => command.slice(1)); - this._skipTerminalCommands = DEFAULT_COMMANDS_TO_SKIP_SHELL.filter(defaultCommand => { - return !excludeCommands.includes(defaultCommand); - }).concat(commands); - } - layout(dimension: dom.Dimension): void { this._lastLayoutDimensions = dimension; if (this.disableLayout) { 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 d5e4de735c760..e474f44d569ca 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts @@ -8,12 +8,12 @@ import { Emitter, Event } from '../../../../../base/common/event.js'; import { DisposableStore, IReference } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { IAgentConnection, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult } from '../../../../../platform/agentHost/common/agentService.js'; -import { ActionType, IStateAction } from '../../../../../platform/agentHost/common/state/protocol/actions.js'; -import { IRootState, TerminalClaimKind, type ITerminalState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; -import type { ICreateTerminalParams, IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; -import type { IActionEnvelope, ISessionAction, ITerminalAction, INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js'; -import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult } from '../../../../../platform/agentHost/common/state/sessionProtocol.js'; +import { IAgentConnection, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../../../../../platform/agentHost/common/agentService.js'; +import { ActionType, StateAction } from '../../../../../platform/agentHost/common/state/protocol/actions.js'; +import { RootState, TerminalClaimKind, type TerminalState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import type { 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 { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult } from '../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { AgentHostPty } from '../../browser/agentHostPty.js'; import { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -27,23 +27,23 @@ class MockAgentConnection implements IAgentConnection { readonly clientId = 'test-client'; private _seq = 0; - private readonly _onDidAction = new Emitter(); - readonly onDidAction: Event = this._onDidAction.event; + private readonly _onDidAction = new Emitter(); + readonly onDidAction: Event = this._onDidAction.event; private readonly _onDidNotification = new Emitter(); readonly onDidNotification: Event = this._onDidNotification.event; - readonly dispatchedActions: (ISessionAction | ITerminalAction)[] = []; - readonly createdTerminals: ICreateTerminalParams[] = []; + readonly dispatchedActions: (SessionAction | TerminalAction)[] = []; + readonly createdTerminals: CreateTerminalParams[] = []; readonly disposedTerminals: URI[] = []; readonly subscribedResources: URI[] = []; - private _terminalState: ITerminalState = { + private _terminalState: TerminalState = { title: 'Test Terminal', content: [], claim: { kind: TerminalClaimKind.Client, clientId: 'test-client' }, }; - constructor(initialState?: Partial) { + constructor(initialState?: Partial) { if (initialState) { this._terminalState = { ...this._terminalState, ...initialState }; } @@ -53,7 +53,7 @@ class MockAgentConnection implements IAgentConnection { return ++this._seq; } - async createTerminal(params: ICreateTerminalParams): Promise { + async createTerminal(params: CreateTerminalParams): Promise { this.createdTerminals.push(params); } @@ -62,27 +62,27 @@ class MockAgentConnection implements IAgentConnection { } /** Simulate the server sending an action to the client */ - fireAction(action: IStateAction, serverSeq = 1): void { + fireAction(action: StateAction, serverSeq = 1): void { this._onDidAction.fire({ action, serverSeq, origin: { clientId: 'server', clientSeq: 0 } }); } // ---- Unused IAgentService methods (stubs) ----- - async authenticate(_params: IAuthenticateParams): Promise { return { authenticated: true }; } + async authenticate(_params: AuthenticateParams): Promise { return { authenticated: true }; } async listSessions(): Promise { return []; } async createSession(_config?: IAgentCreateSessionConfig): Promise { return URI.parse('copilot:///test'); } - async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise { return { schema: { type: 'object', properties: {} }, values: {} }; } - async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { return { items: [] }; } + async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise { return { schema: { type: 'object', properties: {} }, values: {} }; } + async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { return { items: [] }; } async disposeSession(_session: URI): Promise { } async shutdown(): Promise { } - async resourceList(_uri: URI): Promise { return { entries: [] }; } - async resourceRead(_uri: URI): Promise { return { data: '', encoding: 'utf-8' } as IResourceReadResult; } - async resourceWrite(_params: IResourceWriteParams): Promise { return {}; } - async resourceCopy(_params: IResourceCopyParams): Promise { return {}; } - async resourceDelete(_params: IResourceDeleteParams): Promise { return {}; } - async resourceMove(_params: IResourceMoveParams): Promise { return {}; } + async resourceList(_uri: URI): Promise { return { entries: [] }; } + async resourceRead(_uri: URI): Promise { return { data: '', encoding: 'utf-8' } as ResourceReadResult; } + async resourceWrite(_params: ResourceWriteParams): Promise { return {}; } + async resourceCopy(_params: ResourceCopyParams): Promise { return {}; } + async resourceDelete(_params: ResourceDeleteParams): Promise { return {}; } + async resourceMove(_params: ResourceMoveParams): Promise { return {}; } // ---- IAgentConnection new API (stubs for tests) ----- - readonly rootState: IAgentSubscription = { + readonly rootState: IAgentSubscription = { value: undefined, verifiedValue: undefined, onDidChange: Event.None, @@ -90,10 +90,10 @@ class MockAgentConnection implements IAgentConnection { onDidApplyAction: Event.None, }; getSubscription(_kind: StateComponents, _resource: URI): IReference> { - const onDidChange = new Emitter(); - const onWillApplyAction = new Emitter(); - const onDidApplyAction = new Emitter(); - const sub: IAgentSubscription = { + const onDidChange = new Emitter(); + const onWillApplyAction = new Emitter(); + const onDidApplyAction = new Emitter(); + const sub: IAgentSubscription = { value: this._terminalState, verifiedValue: this._terminalState, onDidChange: onDidChange.event, @@ -115,7 +115,7 @@ class MockAgentConnection implements IAgentConnection { getSubscriptionUnmanaged(_kind: StateComponents, _resource: URI): IAgentSubscription | undefined { return undefined; } - dispatch(action: ISessionAction | ITerminalAction): void { + dispatch(action: SessionAction | TerminalAction): void { this.dispatchedActions.push(action); } diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalConfigurationService.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalConfigurationService.test.ts index 206b113186ca2..8fb26ae1469f7 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalConfigurationService.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalConfigurationService.test.ts @@ -13,6 +13,7 @@ import { ConfigurationTarget, IConfigurationService } from '../../../../../platf import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ITerminalConfigurationService, LinuxDistro } from '../../browser/terminal.js'; +import { DEFAULT_COMMANDS_TO_SKIP_SHELL } from '../../common/terminal.js'; import { TestTerminalConfigurationService, workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; suite('Workbench - TerminalConfigurationService', () => { @@ -54,6 +55,33 @@ suite('Workbench - TerminalConfigurationService', () => { }); }); + suite('shouldCommandSkipShell', () => { + test('should include defaults and added commands', () => { + const command = 'test.command'; + const terminalConfigurationService = createTerminalConfigationService({ + terminal: { + integrated: { + commandsToSkipShell: [command] + } + } + }); + strictEqual(terminalConfigurationService.shouldCommandSkipShell(command), true); + strictEqual(terminalConfigurationService.shouldCommandSkipShell(DEFAULT_COMMANDS_TO_SKIP_SHELL[0]), true); + }); + + test('should remove excluded defaults', () => { + const defaultCommand = DEFAULT_COMMANDS_TO_SKIP_SHELL[0]; + const terminalConfigurationService = createTerminalConfigationService({ + terminal: { + integrated: { + commandsToSkipShell: [`-${defaultCommand}`] + } + } + }); + strictEqual(terminalConfigurationService.shouldCommandSkipShell(defaultCommand), false); + }); + }); + function createTerminalConfigationService(config: any, linuxDistro?: LinuxDistro): ITerminalConfigurationService { const instantiationService = new TestInstantiationService(); instantiationService.set(IConfigurationService, new TestConfigurationService(config)); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineBackgroundDetachRewriter.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineBackgroundDetachRewriter.ts index 2687bc137ab09..842fe8807f423 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineBackgroundDetachRewriter.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineBackgroundDetachRewriter.ts @@ -46,8 +46,15 @@ export class CommandLineBackgroundDetachRewriter extends Disposable implements I } private _rewriteForPosix(options: ICommandLineRewriterOptions): ICommandLineRewriterResult { + // If the command already ends with a single trailing `&` (background operator, + // as opposed to `&&` for command chaining), don't append another one. + const trimmed = options.commandLine.trimEnd(); + const endsWithBackgroundAmp = /(?:^|[^&])&$/.test(trimmed); + const rewritten = endsWithBackgroundAmp + ? `nohup ${trimmed}` + : `nohup ${options.commandLine} &`; return { - rewritten: `nohup ${options.commandLine} &`, + rewritten, reasoning: 'Wrapped background command with nohup to survive terminal shutdown', forDisplay: options.commandLine, }; 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 a9d271b53d714..93b432e4e6d78 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -18,6 +18,7 @@ import { OperatingSystem, OS } from '../../../../../../base/common/platform.js'; import { count } from '../../../../../../base/common/strings.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; import { localize } from '../../../../../../nls.js'; +import { ConfirmationOptionKind } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { IInstantiationService, type ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -330,7 +331,7 @@ export async function createRunInTerminalToolData( toolReferenceName: TOOL_REFERENCE_NAME, legacyToolReferenceFullNames: LEGACY_TOOL_REFERENCE_FULL_NAMES, displayName: localize('runInTerminalTool.displayName', 'Run in Terminal'), - modelDescription: `${modelDescription}\n\nExecution mode:\n- mode='sync': wait for completion up to timeout; if still running, return with a terminal ID.\n- mode='async': wait for an initial idle/output signal, then return with terminal output snapshot and ID. Timeout caps how long to wait for the initial idle/output signal.\n- Prefer mode='sync' for commands that will prompt for interactive input (e.g., npm init, interactive installers, configuration wizards).\n\nTerminal notifications: When an async command finishes or a sync command times out, you will be automatically notified on your next turn with the exit code and terminal output. You will also be notified if the terminal needs input. Use ${TerminalToolId.GetTerminalOutput} to check output before then. Do NOT poll or sleep to wait for completion.`, + modelDescription: `${modelDescription}\n\nExecution mode:\n- mode='sync': wait for completion (optionally capped by timeout); if still running when timeout elapses, return with a terminal ID.\n- mode='async': wait for an initial idle/output signal, then return with terminal output snapshot and ID. Timeout caps how long to wait for the initial idle/output signal.\n- Prefer mode='sync' for commands that will prompt for interactive input (e.g., npm init, interactive installers, configuration wizards).\n\nTimeout parameter: Only set 'timeout' when you want a hard cap on how long the tool tracks the command. Omit it to let the command run to completion. Package installs, builds, and long-running scripts should usually omit the timeout rather than guessing a value.\n\nTerminal notifications: When an async command finishes or a sync command times out, you will be automatically notified on your next turn with the exit code and terminal output. You will also be notified if the terminal needs input. Use ${TerminalToolId.GetTerminalOutput} to check output before then. Do NOT poll or sleep to wait for completion.`, userDescription: localize('runInTerminalTool.userDescription', 'Run commands in the terminal'), source: ToolDataSource.Internal, icon: Codicon.terminal, @@ -354,10 +355,10 @@ export async function createRunInTerminalToolData( }, timeout: { type: 'number', - description: 'Timeout in milliseconds that determines how long to wait before returning. Use 0 for no timeout.', + description: 'Optional hard cap in milliseconds on how long the tool tracks the command before returning. Omit to let the command run to completion (recommended for package installs, builds, and long-running scripts). Use 0 to explicitly indicate no timeout.', }, }, - required: ['command', 'explanation', 'goal', 'mode', 'timeout'] + required: ['command', 'explanation', 'goal', 'mode'] } }; } @@ -716,9 +717,9 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { "The following dependencies required for sandboxed execution are not installed: {0}. Would you like to install them?", depsList )), - customButtons: [ - localize('runInTerminal.missingDeps.install', "Install"), - localize('runInTerminal.missingDeps.cancel', "Cancel"), + customOptions: [ + { id: 'install', label: localize('runInTerminal.missingDeps.install', "Install"), kind: ConfirmationOptionKind.Approve }, + { id: 'cancel', label: localize('runInTerminal.missingDeps.cancel', "Cancel"), kind: ConfirmationOptionKind.Deny }, ], }; } @@ -1112,8 +1113,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { // Handle missing sandbox dependencies install flow. // The user was shown a confirmation window in prepareToolInvocation. if (toolSpecificData.missingSandboxDependencies?.length) { - const installButton = localize('runInTerminal.missingDeps.install', "Install"); - if (invocation.selectedCustomButton === installButton) { + if (invocation.selectedCustomButton === 'install') { // Install dependencies, focus terminal for sudo password, wait for completion const sessionResource = invocation.context.sessionResource; const { exitCode } = await this._terminalSandboxService.installMissingSandboxDependencies(toolSpecificData.missingSandboxDependencies, sessionResource, token, { @@ -1177,17 +1177,12 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { }; } if (executionOptions.mode === 'sync' && args.timeout === undefined) { - if (args.isBackground === false) { - // Legacy path: isBackground=false didn't require timeout, default to no timeout - args.timeout = 0; - } else { - return { - content: [{ - kind: 'text', - value: 'Error: timeout is required for mode=sync and must be provided in milliseconds (use 0 for no timeout).' - }] - }; - } + // Timeout is optional for mode=sync: when omitted, the tool waits for + // the command to complete with no hard cap. Models frequently pick + // timeouts that are too short for package installs, builds, and + // long-running scripts, which causes the command to be moved to the + // background unnecessarily. + args.timeout = 0; } const chatSessionResource = invocation.context.sessionResource; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineBackgroundDetachRewriter.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineBackgroundDetachRewriter.test.ts index b092b64f33d47..cddcc474d7195 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineBackgroundDetachRewriter.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineBackgroundDetachRewriter.test.ts @@ -68,6 +68,30 @@ suite('CommandLineBackgroundDetachRewriter', () => { forDisplay: 'flask run', }); }); + + test('should not duplicate trailing & when command already backgrounds itself', () => { + deepStrictEqual(rewriter.rewrite(createOptions('pypi-server ... &', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: 'nohup pypi-server ... &', + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'pypi-server ... &', + }); + }); + + test('should not duplicate trailing & when command ends with chained background command', () => { + deepStrictEqual(rewriter.rewrite(createOptions('cd /app && python3 service.py &', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: 'nohup cd /app && python3 service.py &', + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'cd /app && python3 service.py &', + }); + }); + + test('should trim trailing whitespace before detecting existing &', () => { + deepStrictEqual(rewriter.rewrite(createOptions('node server.js & ', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: 'nohup node server.js &', + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'node server.js & ', + }); + }); }); suite('POSIX (zsh)', () => { 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 d750abd4eb3c1..e30ef1be5965a 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 @@ -360,7 +360,7 @@ suite('RunInTerminalTool', () => { // The tool should return confirmation messages for the user ok(result, 'Expected prepared invocation to be defined'); ok(result?.confirmationMessages, 'Expected confirmationMessages when deps are missing'); - ok(result?.confirmationMessages?.customButtons?.length === 2, 'Expected two custom buttons'); + ok(result?.confirmationMessages?.customOptions?.length === 2, 'Expected two custom options'); // missingDependencies should be in toolSpecificData so invoke can handle it strictEqual((result?.toolSpecificData as IChatTerminalToolInvocationData | undefined)?.missingSandboxDependencies?.length, 1); }); diff --git a/src/vs/workbench/contrib/timeline/browser/timeline.contribution.ts b/src/vs/workbench/contrib/timeline/browser/timeline.contribution.ts index acf3e73a07c95..64c6b1a52f0fc 100644 --- a/src/vs/workbench/contrib/timeline/browser/timeline.contribution.ts +++ b/src/vs/workbench/contrib/timeline/browser/timeline.contribution.ts @@ -5,12 +5,11 @@ import { localize } from '../../../../nls.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; -import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { IViewsRegistry, IViewDescriptor, Extensions as ViewExtensions } from '../../../common/views.js'; import { VIEW_CONTAINER } from '../../files/browser/explorerViewlet.js'; import { ITimelineService, TimelinePaneId } from '../common/timeline.js'; -import { TimelineHasProviderContext, TimelineService } from '../common/timelineService.js'; +import { TimelineHasProviderContext } from '../common/timelineService.js'; import { TimelinePane } from './timelinePane.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../platform/configuration/common/configurationRegistry.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; @@ -103,5 +102,3 @@ MenuRegistry.appendMenuItem(MenuId.TimelineTitle, { order: 100, icon: timelineFilter } satisfies ISubmenuItem); - -registerSingleton(ITimelineService, TimelineService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/timeline/browser/timeline.service.contribution.ts b/src/vs/workbench/contrib/timeline/browser/timeline.service.contribution.ts new file mode 100644 index 0000000000000..a8ff97e0ddbb1 --- /dev/null +++ b/src/vs/workbench/contrib/timeline/browser/timeline.service.contribution.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { ITimelineService } from '../common/timeline.js'; +import { TimelineService } from '../common/timelineService.js'; + +registerSingleton(ITimelineService, TimelineService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/update/browser/media/postUpdateWidget.css b/src/vs/workbench/contrib/update/browser/media/postUpdateWidget.css index 119744c695498..5482a816b1831 100644 --- a/src/vs/workbench/contrib/update/browser/media/postUpdateWidget.css +++ b/src/vs/workbench/contrib/update/browser/media/postUpdateWidget.css @@ -3,27 +3,140 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/* + * The widget supplies its own padding and border-radius via `.post-update-widget`, + * so we strip the compact hover's contents padding. The selector below is at least as + * specific as `.monaco-hover.workbench-hover.compact .hover-contents` and loads after it, + * so it wins without `!important`. + */ +.monaco-hover.workbench-hover.post-update-widget-hover .hover-contents { + padding: 0; +} + .post-update-widget { + position: relative; display: flex; flex-direction: column; - gap: 12px; - padding: 6px 6px; - min-width: 300px; - max-width: 550px; + min-width: 320px; + max-width: 420px; color: var(--vscode-descriptionForeground); font-size: var(--vscode-bodyFontSize-small); + border-radius: var(--vscode-cornerRadius-medium); + overflow: hidden; +} + +/* Banner */ +.post-update-widget .banner { + position: relative; + aspect-ratio: 16 / 5; + min-height: 90px; + background-position: center; + background-size: cover; + background-repeat: no-repeat; + /* Default: VS Code "copilot free" gradient (recreated with layered radial gradients) */ + background-color: #0b1020; + background-image: + radial-gradient(ellipse 60% 80% at 12% 90%, rgba(132, 204, 22, 0.55), transparent 60%), + radial-gradient(ellipse 70% 90% at 25% 30%, rgba(56, 189, 248, 0.45), transparent 65%), + radial-gradient(ellipse 90% 100% at 75% 60%, rgba(99, 102, 241, 0.55), transparent 70%), + radial-gradient(ellipse 60% 80% at 100% 100%, rgba(30, 27, 75, 0.7), transparent 70%), + linear-gradient(135deg, #0b1020 0%, #1e1b4b 100%); +} + +.post-update-widget .banner-close { + position: absolute; + top: 8px; + right: 8px; + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + color: var(--vscode-icon-foreground); + border: none; + border-radius: var(--vscode-cornerRadius-small); + cursor: pointer; + padding: 0; + z-index: 1; +} + +.post-update-widget .banner-close:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +/* Body */ +.post-update-widget .body { + display: flex; + flex-direction: column; + gap: 14px; + padding: 16px; +} + +/* Badge */ +.post-update-widget .badge { + align-self: flex-start; + padding: 2px 8px; + border-radius: var(--vscode-cornerRadius-small); + background-color: var(--vscode-extensionBadge-remoteBackground, var(--vscode-badge-background)); + color: var(--vscode-extensionBadge-remoteForeground, var(--vscode-badge-foreground)); + font-size: 11px; + font-weight: 600; + line-height: 1.4; + text-transform: none; +} + +/* Title */ +.post-update-widget .title { + font-weight: 600; + font-size: 16px; + line-height: 1.3; + color: var(--vscode-foreground); +} + +/* Features */ +.post-update-widget .features { + display: flex; + flex-direction: column; + gap: 12px; } -/* Header */ -.post-update-widget .header { +.post-update-widget .feature { + display: grid; + grid-template-columns: 20px 1fr; + column-gap: 12px; + align-items: start; +} + +.post-update-widget .feature-icon { display: flex; - justify-content: space-between; align-items: center; + justify-content: center; + color: var(--vscode-foreground); + margin-top: 2px; + font-size: 16px; +} + +.post-update-widget .feature-text { + display: flex; + flex-direction: column; + gap: 2px; } -.post-update-widget .header .title { +.post-update-widget .feature-title { font-weight: 600; - font-size: var(--vscode-bodyFontSize); + color: var(--vscode-foreground); + font-size: var(--vscode-bodyFontSize-small); +} + +.post-update-widget .feature-description { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-bodyFontSize-small); + line-height: 1.4; +} + +/* Markdown fallback */ +.post-update-widget .update-markdown { color: var(--vscode-foreground); } @@ -33,12 +146,14 @@ justify-content: flex-end; align-items: center; gap: 8px; + margin-top: 4px; } .post-update-widget .button-bar button { - padding: 4px 12px; + padding: 8px 16px; border-radius: var(--vscode-cornerRadius-small); font-size: var(--vscode-bodyFontSize-small); + font-weight: 600; cursor: pointer; white-space: nowrap; } @@ -66,3 +181,9 @@ .post-update-widget .update-button-primary:hover { background-color: var(--vscode-button-hoverBackground); } + +.post-update-widget .update-button-full-width { + width: 100%; + justify-content: center; + text-align: center; +} diff --git a/src/vs/workbench/contrib/update/browser/postUpdateWidget.ts b/src/vs/workbench/contrib/update/browser/postUpdateWidget.ts index 978b2a15b8f64..6e0df0ff7d16c 100644 --- a/src/vs/workbench/contrib/update/browser/postUpdateWidget.ts +++ b/src/vs/workbench/contrib/update/browser/postUpdateWidget.ts @@ -25,6 +25,9 @@ import { IHostService } from '../../../services/host/browser/host.js'; import { ShowCurrentReleaseNotesActionId } from '../common/update.js'; import { IParsedUpdateInfoInput, parseUpdateInfoInput } from '../common/updateInfoParser.js'; import { getUpdateInfoUrl, isMajorMinorVersionChange } from '../common/updateUtils.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { URI } from '../../../../base/common/uri.js'; import './media/postUpdateWidget.css'; const LAST_KNOWN_VERSION_KEY = 'postUpdateWidget/lastKnownVersion'; @@ -40,6 +43,8 @@ interface ILastKnownVersion { */ export class PostUpdateWidgetContribution extends Disposable implements IWorkbenchContribution { + private static idCounter = 0; + constructor( @ICommandService private readonly commandService: ICommandService, @IConfigurationService private readonly configurationService: IConfigurationService, @@ -88,7 +93,7 @@ export class PostUpdateWidgetContribution extends Disposable implements IWorkben const contentDisposables = new DisposableStore(); const target = this.layoutService.mainContainer; const { clientWidth } = target; - const maxWidth = 550; + const maxWidth = 420; const x = Math.max(clientWidth - maxWidth - 80, 16); this.hoverService.showInstantHover({ @@ -99,8 +104,10 @@ export class PostUpdateWidgetContribution extends Disposable implements IWorkben y: 40, dispose: () => contentDisposables.dispose() }, + additionalClasses: ['post-update-widget-hover'], persistence: { sticky: true }, - appearance: { showPointer: false, compact: true, maxHeightRatio: 0.8 }, + appearance: { showPointer: false, compact: true, maxHeightRatio: 1 }, + trapFocus: true, }, true); } @@ -132,33 +139,98 @@ export class PostUpdateWidgetContribution extends Disposable implements IWorkben return info; } - private buildContent({ markdown, buttons }: IParsedUpdateInfoInput, disposables: DisposableStore): HTMLElement { + private buildContent(info: IParsedUpdateInfoInput, disposables: DisposableStore): HTMLElement { + const { markdown, buttons, bannerImageUrl, badge, title, features } = info; const container = dom.$('.post-update-widget'); + const titleId = `post-update-widget-title-${PostUpdateWidgetContribution.idCounter++}`; + container.setAttribute('role', 'dialog'); + container.setAttribute('aria-labelledby', titleId); + // Escape-to-dismiss is handled by the hover widget itself (HoverWidget listens for Escape + // on its container and disposes the hover). + + // Banner (decorative). Default is a CSS gradient; an image from the markdown frontmatter overrides it. + const banner = dom.append(container, dom.$('.banner')); + banner.setAttribute('aria-hidden', 'true'); + const safeBannerUrl = sanitizeBannerImageUrl(bannerImageUrl); + if (safeBannerUrl) { + // Use setProperty + JSON.stringify to safely quote the URL inside CSS without breaking out. + banner.style.setProperty('background-image', `url(${JSON.stringify(safeBannerUrl)})`); + } - // Header - const header = dom.append(container, dom.$('.header')); - const title = dom.append(header, dom.$('.title')); - title.textContent = localize('postUpdate.title', "New in {0}", this.productService.version); - - // Markdown - const markdownContainer = dom.append(container, dom.$('.update-markdown')); - const rendered = disposables.add(this.markdownRendererService.render( - new MarkdownString(markdown, { - isTrusted: true, - supportHtml: true, - supportThemeIcons: true, - }), - { - actionHandler: (link, mdStr) => { - openLinkFromMarkdown(this.openerService, link, mdStr.isTrusted); - this.hoverService.hideHover(true); - }, - })); - markdownContainer.appendChild(rendered.element); + // Close button is a sibling of the banner so it isn't a focusable descendant of an aria-hidden region. + const closeButton = dom.append(container, dom.$('button.banner-close')) as HTMLButtonElement; + closeButton.setAttribute('aria-label', localize('postUpdate.close', "Close")); + const closeIcon = dom.append(closeButton, dom.$(ThemeIcon.asCSSSelector(Codicon.close))); + closeIcon.setAttribute('aria-hidden', 'true'); + disposables.add(dom.addDisposableListener(closeButton, 'click', () => { + this.hoverService.hideHover(true); + })); + + // Body + const body = dom.append(container, dom.$('.body')); + + // Badge + if (badge) { + const badgeEl = dom.append(body, dom.$('.badge')); + badgeEl.textContent = badge; + } + + // Title + const titleEl = dom.append(body, dom.$('.title')); + titleEl.id = titleId; + titleEl.textContent = title ?? localize('postUpdate.title', "New in {0}", this.productService.version); + + // Features (preferred) or markdown body + if (features?.length) { + const list = dom.append(body, dom.$('.features')); + list.setAttribute('role', 'list'); + for (const feature of features) { + const row = dom.append(list, dom.$('.feature')); + row.setAttribute('role', 'listitem'); + const iconEl = dom.append(row, dom.$('.feature-icon')); + const iconId = feature.icon ?? Codicon.sparkle.id; + const themeIcon = ThemeIcon.fromId(iconId); + iconEl.classList.add(...ThemeIcon.asClassNameArray(themeIcon)); + iconEl.setAttribute('aria-hidden', 'true'); + const text = dom.append(row, dom.$('.feature-text')); + const featureTitle = dom.append(text, dom.$('.feature-title')); + featureTitle.textContent = feature.title; + const featureDescription = dom.append(text, dom.$('.feature-description')); + // Render description as markdown so it can include inline links and emphasis. + const rendered = disposables.add(this.markdownRendererService.render( + new MarkdownString(feature.description, { + isTrusted: true, + supportThemeIcons: true, + }), + { + actionHandler: (link, mdStr) => { + openLinkFromMarkdown(this.openerService, link, mdStr.isTrusted); + this.hoverService.hideHover(true); + }, + })); + featureDescription.appendChild(rendered.element); + } + } else if (markdown) { + const markdownContainer = dom.append(body, dom.$('.update-markdown')); + const rendered = disposables.add(this.markdownRendererService.render( + new MarkdownString(markdown, { + isTrusted: true, + supportHtml: true, + supportThemeIcons: true, + }), + { + actionHandler: (link, mdStr) => { + openLinkFromMarkdown(this.openerService, link, mdStr.isTrusted); + this.hoverService.hideHover(true); + }, + })); + markdownContainer.appendChild(rendered.element); + } // Buttons if (buttons?.length) { - const buttonBar = dom.append(container, dom.$('.button-bar')); + const buttonBar = dom.append(body, dom.$('.button-bar')); + const isSingleButton = buttons.length === 1; let seenSecondary = false; for (const { label, style, commandId, args } of buttons) { @@ -175,6 +247,10 @@ export class PostUpdateWidgetContribution extends Disposable implements IWorkben button.classList.add('update-button-primary'); } + if (isSingleButton) { + button.classList.add('update-button-full-width'); + } + disposables.add(dom.addDisposableListener(button, 'click', () => { this.telemetryService.publicLog2( 'workbenchActionExecuted', @@ -215,3 +291,25 @@ export class PostUpdateWidgetContribution extends Disposable implements IWorkben return false; } } + +/** + * Validates a banner image URL from update info. Only `https:` and `data:image/*` schemes are + * allowed to prevent CSS-injection or unexpected protocol handlers being invoked from the markdown payload. + */ +function sanitizeBannerImageUrl(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + try { + const uri = URI.parse(value, true); + if (uri.scheme === 'https') { + return uri.toString(true); + } + if (uri.scheme === 'data' && /^image\//i.test(uri.path)) { + return uri.toString(true); + } + } catch { + // fall through + } + return undefined; +} diff --git a/src/vs/workbench/contrib/update/browser/update.ts b/src/vs/workbench/contrib/update/browser/update.ts index 2d6b676479c1c..7639dd03821af 100644 --- a/src/vs/workbench/contrib/update/browser/update.ts +++ b/src/vs/workbench/contrib/update/browser/update.ts @@ -176,10 +176,11 @@ export class ProductContribution implements IWorkbenchContribution { const lastVersion = tryParseVersion(storageService.get(ProductContribution.KEY, StorageScope.APPLICATION, '')); const currentVersion = tryParseVersion(productService.version); const shouldShowReleaseNotes = configurationService.getValue('update.showReleaseNotes'); + const shouldShowPostInstallInfo = configurationService.getValue('update.showPostInstallInfo'); const releaseNotesUrl = productService.releaseNotesUrl; - // was there a major/minor update? if so, open release notes - if (shouldShowReleaseNotes && !environmentService.skipReleaseNotes && releaseNotesUrl && lastVersion && currentVersion && isMajorMinorUpdate(lastVersion, currentVersion)) { + // was there a major/minor update? if so, open release notes (unless post-install info is enabled, which takes over) + if (shouldShowReleaseNotes && !shouldShowPostInstallInfo && !environmentService.skipReleaseNotes && releaseNotesUrl && lastVersion && currentVersion && isMajorMinorUpdate(lastVersion, currentVersion)) { showReleaseNotesInEditor(instantiationService, productService.version, false) .then(undefined, () => { notificationService.prompt( diff --git a/src/vs/workbench/contrib/update/common/updateInfoParser.ts b/src/vs/workbench/contrib/update/common/updateInfoParser.ts index e34a9a6901a44..faea8c5239f95 100644 --- a/src/vs/workbench/contrib/update/common/updateInfoParser.ts +++ b/src/vs/workbench/contrib/update/common/updateInfoParser.ts @@ -3,7 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { hasKey } from '../../../../base/common/types.js'; +import { hasKey, Mutable } from '../../../../base/common/types.js'; + +const MAX_FEATURES = 5; export type UpdateInfoButtonStyle = 'primary' | 'secondary'; @@ -14,9 +16,37 @@ export interface IUpdateInfoButton { readonly style?: UpdateInfoButtonStyle; } +export interface IUpdateInfoFeature { + /** + * Optional Codicon icon identifier (e.g. `$(sparkle)` or `$(lightbulb)`) displayed + * alongside the feature title. + */ + readonly icon?: string; + /** Short title for the feature highlight. */ + readonly title: string; + /** One-line description of the feature. */ + readonly description: string; +} + export interface IParsedUpdateInfoInput { + /** Markdown body rendered in the update-info widget. */ readonly markdown: string; + /** Optional action buttons shown below the markdown content. */ readonly buttons?: IUpdateInfoButton[]; + /** + * Optional URL for a banner/hero image shown at the top of the widget. + * Must be an `https://` URL; non-HTTPS URLs are ignored. + */ + readonly bannerImageUrl?: string; + /** Optional short badge label (e.g. `"New"`) displayed on the widget. */ + readonly badge?: string; + /** Optional heading title rendered above the markdown body. */ + readonly title?: string; + /** + * Optional list of feature highlights. At most {@link MAX_FEATURES} entries + * (currently 5) are displayed; any additional entries are silently dropped. + */ + readonly features?: IUpdateInfoFeature[]; } /** @@ -24,13 +54,19 @@ export interface IParsedUpdateInfoInput { * * Supported formats: * - * **JSON envelope** - a single JSON object with `markdown` and optional `buttons`: + * **JSON envelope** - a single JSON object with `markdown` and optional fields: * ```json * { * "markdown": "$(info) **Feature**
Description...", + * "title": "What's New", + * "badge": "New", + * "bannerImageUrl": "https://example.com/banner.png", * "buttons": [ * { "label": "Release Notes", "commandId": "update.showCurrentReleaseNotes", "style": "secondary" }, * { "label": "Open Sessions", "commandId": "workbench.action.chat.open", "style": "primary" } + * ], + * "features": [ + * { "icon": "$(sparkle)", "title": "Feature", "description": "Short description" } * ] * } * ``` @@ -38,7 +74,7 @@ export interface IParsedUpdateInfoInput { * **Block frontmatter** - YAML-style `---` delimiters wrapping a JSON metadata block: * ``` * --- - * { "buttons": [...] } + * { "buttons": [...], "features": [...] } * --- * $(info) **Feature**
Description... * ``` @@ -48,6 +84,8 @@ export interface IParsedUpdateInfoInput { * --- { "buttons": [...] } --- * $(info) **Feature**
Description... * ``` + * + * At most 5 feature entries are retained; any additional ones are silently dropped. */ export function parseUpdateInfoInput(text: string): IParsedUpdateInfoInput { const normalized = text.replace(/^\uFEFF/, ''); @@ -61,20 +99,30 @@ function tryParseUpdateInfoEnvelope(text: string): IParsedUpdateInfoInput | unde } try { - const value = JSON.parse(trimmed) as { markdown?: string; buttons?: unknown }; + const value = JSON.parse(trimmed) as { markdown?: string; buttons?: unknown; bannerImageUrl?: unknown; badge?: unknown; title?: unknown; features?: unknown }; if (typeof value.markdown !== 'string') { return undefined; } - return { - markdown: value.markdown, - buttons: parseUpdateInfoButtons(value.buttons), - }; + return buildParsedInput(value.markdown, value); } catch { return undefined; } } +function buildParsedInput(markdown: string, meta: { buttons?: unknown; bannerImageUrl?: unknown; badge?: unknown; title?: unknown; features?: unknown }): IParsedUpdateInfoInput { + const result: Mutable = { + markdown, + buttons: parseUpdateInfoButtons(meta.buttons), + }; + if (typeof meta.bannerImageUrl === 'string') { result.bannerImageUrl = meta.bannerImageUrl; } + if (typeof meta.badge === 'string') { result.badge = meta.badge; } + if (typeof meta.title === 'string') { result.title = meta.title; } + const features = parseUpdateInfoFeatures(meta.features); + if (features) { result.features = features; } + return result; +} + function parseUpdateInfoFrontmatter(text: string): IParsedUpdateInfoInput { const blockMatch = text.match(/^---[ \t]*\r?\n(?[\s\S]*?)\r?\n---[ \t]*(?:\r?\n(?[\s\S]*))?$/); if (blockMatch?.groups) { @@ -91,11 +139,8 @@ function parseUpdateInfoFrontmatter(text: string): IParsedUpdateInfoInput { function parseUpdateInfoFrontmatterMatch(text: string, jsonText: string, markdown: string): IParsedUpdateInfoInput { try { - const meta = JSON.parse(jsonText) as { buttons?: unknown }; - return { - markdown, - buttons: parseUpdateInfoButtons(meta.buttons), - }; + const meta = JSON.parse(jsonText) as { buttons?: unknown; bannerImageUrl?: unknown; badge?: unknown; title?: unknown; features?: unknown }; + return buildParsedInput(markdown, meta); } catch { return { markdown: text }; } @@ -128,3 +173,33 @@ function parseUpdateInfoButtons(buttons: unknown): IUpdateInfoButton[] | undefin return parsedButtons.length ? parsedButtons : undefined; } + +/** + * Parses an array of feature-highlight objects from raw update-info metadata. + * Only the first {@link MAX_FEATURES} valid entries are returned; the rest are + * discarded. Each entry must have at minimum a `title` and `description` string. + * The optional `icon` field accepts a Codicon identifier (e.g. `$(sparkle)`). + */ +function parseUpdateInfoFeatures(features: unknown): IUpdateInfoFeature[] | undefined { + if (!Array.isArray(features)) { + return undefined; + } + + const parsed: IUpdateInfoFeature[] = []; + for (const feature of features) { + if (typeof feature !== 'object' || feature === null) { + continue; + } + const candidate = feature as { title?: unknown; description?: unknown; icon?: unknown }; + if (typeof candidate.title !== 'string' || typeof candidate.description !== 'string') { + continue; + } + const icon = typeof candidate.icon === 'string' ? candidate.icon : undefined; + parsed.push({ icon, title: candidate.title, description: candidate.description }); + if (parsed.length >= MAX_FEATURES) { + break; + } + } + + return parsed.length ? parsed : undefined; +} diff --git a/src/vs/workbench/contrib/update/test/common/updateInfoParser.test.ts b/src/vs/workbench/contrib/update/test/common/updateInfoParser.test.ts index e60a530bdd53d..3302af22f36f1 100644 --- a/src/vs/workbench/contrib/update/test/common/updateInfoParser.test.ts +++ b/src/vs/workbench/contrib/update/test/common/updateInfoParser.test.ts @@ -155,5 +155,117 @@ suite('updateInfoParser', () => { buttons: [{ label: 'X', commandId: 'cmd', style: undefined, args: undefined }], }); }); + + test('JSON envelope with bannerImageUrl, badge, title, and features', () => { + const input = JSON.stringify({ + markdown: 'Body', + bannerImageUrl: 'https://example.com/banner.png', + badge: 'New', + title: 'What\'s New', + features: [ + { icon: '$(sparkle)', title: 'Feature A', description: 'Does A' }, + { title: 'Feature B', description: 'Does B' }, + ], + }); + + assert.deepStrictEqual(parseUpdateInfoInput(input), { + markdown: 'Body', + buttons: undefined, + bannerImageUrl: 'https://example.com/banner.png', + badge: 'New', + title: 'What\'s New', + features: [ + { icon: '$(sparkle)', title: 'Feature A', description: 'Does A' }, + { icon: undefined, title: 'Feature B', description: 'Does B' }, + ], + }); + }); + + test('block frontmatter with bannerImageUrl, badge, title, and features', () => { + const meta = { + bannerImageUrl: 'https://example.com/banner.png', + badge: 'Preview', + title: 'Highlights', + features: [{ title: 'Feature', description: 'Desc' }], + }; + const input = `---\n${JSON.stringify(meta)}\n---\nBody text`; + + assert.deepStrictEqual(parseUpdateInfoInput(input), { + markdown: 'Body text', + buttons: undefined, + bannerImageUrl: 'https://example.com/banner.png', + badge: 'Preview', + title: 'Highlights', + features: [{ icon: undefined, title: 'Feature', description: 'Desc' }], + }); + }); + + test('ignores non-string bannerImageUrl, badge, and title', () => { + const input = JSON.stringify({ + markdown: 'text', + bannerImageUrl: 123, + badge: { not: 'a string' }, + title: ['nope'], + }); + + assert.deepStrictEqual(parseUpdateInfoInput(input), { + markdown: 'text', + buttons: undefined, + }); + }); + + test('caps features at 5 entries and skips invalid ones', () => { + const input = JSON.stringify({ + markdown: 'text', + features: [ + { title: 'F1', description: 'D1' }, + { title: 'F2' }, // missing description + 'not an object', + null, + { title: 'F3', description: 'D3', icon: '$(star)' }, + { title: 'F4', description: 'D4' }, + { title: 'F5', description: 'D5' }, + { title: 'F6', description: 'D6' }, + { title: 'F7', description: 'D7' }, // dropped (over cap) + ], + }); + + assert.deepStrictEqual(parseUpdateInfoInput(input), { + markdown: 'text', + buttons: undefined, + features: [ + { icon: undefined, title: 'F1', description: 'D1' }, + { icon: '$(star)', title: 'F3', description: 'D3' }, + { icon: undefined, title: 'F4', description: 'D4' }, + { icon: undefined, title: 'F5', description: 'D5' }, + { icon: undefined, title: 'F6', description: 'D6' }, + ], + }); + }); + + test('returns undefined features when all features are invalid', () => { + const input = JSON.stringify({ + markdown: 'text', + features: [{ title: 'OnlyTitle' }, 'string', null], + }); + + assert.deepStrictEqual(parseUpdateInfoInput(input), { + markdown: 'text', + buttons: undefined, + }); + }); + + test('ignores non-string feature icon', () => { + const input = JSON.stringify({ + markdown: 'text', + features: [{ icon: 42, title: 'F', description: 'D' }], + }); + + assert.deepStrictEqual(parseUpdateInfoInput(input), { + markdown: 'text', + buttons: undefined, + features: [{ icon: undefined, title: 'F', description: 'D' }], + }); + }); }); }); diff --git a/src/vs/workbench/contrib/webview/browser/pre/index.html b/src/vs/workbench/contrib/webview/browser/pre/index.html index 9bd67086fd042..da9063a0920fd 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/index.html +++ b/src/vs/workbench/contrib/webview/browser/pre/index.html @@ -5,7 +5,7 @@ + content="default-src 'none'; script-src 'sha256-25y69Rmwe2/7r58SQN/qPNWvAcjm+OR1DVlm3D2jsXc=' 'self'; frame-src 'self'; style-src 'unsafe-inline';"> { - assertIsDefined(navigator.serviceWorker.controller).postMessage({ channel: 'did-load-resource', data }, data.data?.buffer ? [data.data.buffer] : []); + const transfer = data.stream ? [data.stream] : []; + assertIsDefined(navigator.serviceWorker.controller).postMessage({ channel: 'did-load-resource', data }, transfer); }); - hostMessaging.onMessage('did-load-localhost', (_event, data) => { - assertIsDefined(navigator.serviceWorker.controller).postMessage({ channel: 'did-load-localhost', data }); + for (const channel of [ + 'did-load-resource-end', + 'did-load-localhost', + ]) { + hostMessaging.onMessage(channel, (_event, data) => { + assertIsDefined(navigator.serviceWorker.controller).postMessage({ channel, data }); + }); + } + + hostMessaging.onMessage('did-load-resource-chunk', (_event, data) => { + assertIsDefined(navigator.serviceWorker.controller).postMessage({ channel: 'did-load-resource-chunk', data }, data.data?.buffer ? [data.data.buffer] : []); }); navigator.serviceWorker.addEventListener('message', event => { diff --git a/src/vs/workbench/contrib/webview/browser/pre/service-worker.js b/src/vs/workbench/contrib/webview/browser/pre/service-worker.js index 2ae1ee4bfa3ac..66aa364435c72 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/service-worker.js +++ b/src/vs/workbench/contrib/webview/browser/pre/service-worker.js @@ -8,7 +8,7 @@ /** @type {ServiceWorkerGlobalScope} */ const sw = /** @type {any} */ (self); -const VERSION = 4; +const VERSION = 5; const resourceCacheName = `vscode-resource-cache-${VERSION}`; @@ -115,6 +115,13 @@ class RequestStore { /** @type {RequestStore} */ const resourceRequestStore = new RequestStore(); +/** + * Safari fallback: map of active chunk-based streaming responses. + * Maps request id to a WritableStreamDefaultWriter for piping chunks. + * @type {Map>} + */ +const safariResourceStreams = new Map(); + /** * Map of requested localhost origins to optional redirects. */ @@ -158,11 +165,62 @@ sw.addEventListener('message', async (event) => { case 'did-load-resource': { /** @type {ResourceResponse} */ const response = event.data.data; - if (!resourceRequestStore.resolve(response.id, response)) { + if (response.status === 200 || response.status === 206) { + /** @type {ReadableStream} */ + let stream; + if (response.stream) { + // Transferable stream (Chromium/Firefox) + stream = response.stream; + } else { + // Safari fallback: set up a TransformStream for incoming chunks + const transform = new TransformStream(); + const writer = transform.writable.getWriter(); + safariResourceStreams.set(response.id, writer); + writer.closed.then( + () => safariResourceStreams.delete(response.id), + () => safariResourceStreams.delete(response.id) + ); + stream = transform.readable; + } + resourceRequestStore.resolve(response.id, { + status: response.status, + id: response.id, + path: response.path, + mime: response.mime, + etag: response.etag, + mtime: response.mtime, + stream, + range: response.range, + }); + } else if (!resourceRequestStore.resolve(response.id, response)) { console.log('Could not resolve unknown resource', response.path); } return; } + // Safari fallback: chunk-based streaming for browsers without transferable streams + case 'did-load-resource-chunk': { + const data = event.data.data; + const writer = safariResourceStreams.get(data.id); + if (writer) { + writer.write(data.data).catch(() => { + safariResourceStreams.delete(data.id); + }); + } + return; + } + case 'did-load-resource-end': { + const data = event.data.data; + const writer = safariResourceStreams.get(data.id); + if (writer) { + if (data.error) { + writer.abort(new Error('Stream error')).catch(() => { /* already cleaning up */ }); + } else { + writer.close().catch(() => { /* already cleaning up */ }); + } + safariResourceStreams.delete(data.id); + } + return; + } case 'did-load-localhost': { const data = event.data.data; if (!localhostRequestStore.resolve(data.id, data.location)) { @@ -309,46 +367,14 @@ async function processResourceRequest( return unauthorized(); } - if (entry.status !== 200) { + if (entry.status !== 200 && entry.status !== 206) { return notFound(); } - const byteLength = entry.data.byteLength; - - const range = event.request.headers.get('range'); - if (range) { - // To support seeking for videos, we need to handle range requests - const bytes = range.match(/^bytes\=(\d+)\-(\d+)?$/g); - if (bytes) { - // TODO: Right now we are always reading the full file content. This is a bad idea - // for large video files :) - - const start = Number(bytes[1]); - const end = Number(bytes[2]) || byteLength - 1; - return new Response(entry.data.slice(start, end + 1), { - status: 206, - headers: { - ...accessControlHeaders, - 'Content-range': `bytes 0-${end}/${byteLength}`, - } - }); - } else { - // We don't understand the requested bytes - return new Response(null, { - status: 416, - headers: { - ...accessControlHeaders, - 'Content-range': `*/${byteLength}` - } - }); - } - } - /** @type {Record} */ const headers = { ...accessControlHeaders, 'Content-Type': entry.mime, - 'Content-Length': byteLength.toString(), }; if (entry.etag) { @@ -370,17 +396,24 @@ async function processResourceRequest( headers['Cross-Origin-Opener-Policy'] = 'same-origin'; } - const response = new Response(entry.data, { - status: 200, - headers - }); + if (entry.stream) { + // Range responses: the host already read only the requested range, + // so we just pipe the stream through with a 206 status. + if (entry.status === 206 && entry.range) { + headers['Content-Range'] = entry.range; + return new Response(entry.stream, { status: 206, headers }); + } - if (shouldTryCaching && entry.etag) { - caches.open(resourceCacheName).then(cache => { - return cache.put(event.request, response); - }); + const response = new Response(entry.stream, { status: 200, headers }); + + if (shouldTryCaching && entry.etag) { + const responseForCache = response.clone(); + caches.open(resourceCacheName).then(cache => { + return cache.put(event.request, responseForCache); + }); + } + return response; } - return response.clone(); }; /** @type {Response|undefined} */ @@ -392,6 +425,28 @@ async function processResourceRequest( const { requestId, promise } = resourceRequestStore.create(); + // Parse range header to forward to the host so it can read only the needed bytes + /** @type {{ start: number, end?: number } | undefined} */ + let range; + const rangeHeader = event.request.headers.get('range'); + if (rangeHeader) { + const bytes = rangeHeader.match(/^bytes\=(\d+)\-(\d+)?$/); + if (bytes) { + range = { + start: Number(bytes[1]), + end: bytes[2] !== undefined ? Number(bytes[2]) : undefined, + }; + } else { + return new Response(null, { + status: 416, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Range': '*/*', + } + }); + } + } + if (webviewId) { const parentClients = await getOuterIframeClient(webviewId); if (!parentClients.length) { @@ -408,6 +463,7 @@ async function processResourceRequest( path: requestUrlComponents.path, query: requestUrlComponents.query, ifNoneMatch: cached?.headers.get('ETag'), + range, }); } } else if (client.type === 'worker' || client.type === 'sharedworker') { @@ -419,6 +475,7 @@ async function processResourceRequest( path: requestUrlComponents.path, query: requestUrlComponents.query, ifNoneMatch: cached?.headers.get('ETag'), + range, }); } @@ -536,8 +593,9 @@ async function getWorkerClientForId(clientId) { /** * @typedef {( - * | { readonly status: 200, id: number, path: string, mime: string, data: Uint8Array, etag: string|undefined, mtime: number|undefined } - * | { readonly status: 304, id: number, path: string, mime: string, mtime: number|undefined } + * | { readonly status: 200, id: number, path: string, mime: string, stream: ReadableStream, etag: string|undefined, mtime: number | undefined } + * | { readonly status: 206, id: number, path: string, mime: string, stream: ReadableStream, range: string, etag: string|undefined, mtime: number | undefined } + * | { readonly status: 304, id: number, path: string, mime: string, mtime: number | undefined } * | { readonly status: 401, id: number, path: string } * | { readonly status: 404, id: number, path: string } * )} ResourceResponse diff --git a/src/vs/workbench/contrib/webview/browser/resourceLoading.ts b/src/vs/workbench/contrib/webview/browser/resourceLoading.ts index 262a050262dab..1c9f470218013 100644 --- a/src/vs/workbench/contrib/webview/browser/resourceLoading.ts +++ b/src/vs/workbench/contrib/webview/browser/resourceLoading.ts @@ -24,6 +24,7 @@ export namespace WebviewResourceResponse { public readonly etag: string | undefined, public readonly mtime: number | undefined, public readonly mimeType: string, + public readonly size: number, ) { } } @@ -47,6 +48,7 @@ export async function loadLocalResource( options: { ifNoneMatch: string | undefined; roots: ReadonlyArray; + range?: { readonly start: number; readonly end?: number }; }, uriIdentityService: IUriIdentityService, fileService: IFileService, @@ -65,9 +67,19 @@ export async function loadLocalResource( const mime = getWebviewContentMimeType(requestUri); // Use the original path for the mime try { - const result = await fileService.readFileStream(resourceToLoad, { etag: options.ifNoneMatch }, token); + const readOptions: { etag?: string; position?: number; length?: number } = { etag: options.ifNoneMatch }; + if (options.range) { + readOptions.position = options.range.start; + if (options.range.end !== undefined) { + if (options.range.end < options.range.start) { + return WebviewResourceResponse.Failed; + } + readOptions.length = options.range.end - options.range.start + 1; + } + } + const result = await fileService.readFileStream(resourceToLoad, readOptions, token); logService.trace(`Webview.loadLocalResource - Loaded. requestUri=${requestUri}, resourceToLoad=${resourceToLoad}`); - return new WebviewResourceResponse.StreamSuccess(result.value, result.etag, result.mtime, mime); + return new WebviewResourceResponse.StreamSuccess(result.value, result.etag, result.mtime, mime, result.size); } catch (err) { if (err instanceof FileOperationError) { const result = err.fileOperationResult; diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 5d0d61f811308..d838af9b42e38 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -9,12 +9,13 @@ import { parentOriginHash } from '../../../../base/browser/iframe.js'; import { IMouseWheelEvent } from '../../../../base/browser/mouseEvent.js'; import { CodeWindow } from '../../../../base/browser/window.js'; import { promiseWithResolvers, ThrottledDelayer } from '../../../../base/common/async.js'; -import { streamToBuffer, VSBufferReadableStream } from '../../../../base/common/buffer.js'; -import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; +import { Lazy } from '../../../../base/common/lazy.js'; import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { COI } from '../../../../base/common/network.js'; import { observableValue } from '../../../../base/common/observable.js'; +import { listenStream } from '../../../../base/common/stream.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; @@ -97,7 +98,20 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi protected get platform(): string { return 'browser'; } - private readonly _expectedServiceWorkerVersion = 4; // Keep this in sync with the version in service-worker.js + private static readonly _supportsTransferableStreams = new Lazy(() => { + try { + const stream = new ReadableStream(); + const mc = new MessageChannel(); + mc.port1.postMessage(stream, [stream]); + mc.port1.close(); + mc.port2.close(); + return true; + } catch { + return false; + } + }); + + private readonly _expectedServiceWorkerVersion = 5; // Keep this in sync with the version in service-worker.js private _element: HTMLIFrameElement | undefined; protected get element(): HTMLIFrameElement | undefined { return this._element; } @@ -281,7 +295,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi path: decodeURIComponent(entry.path), // This gets re-encoded query: entry.query ? decodeURIComponent(entry.query) : entry.query, }); - this.loadResource(entry.id, uri, entry.ifNoneMatch); + this.loadResource(entry.id, uri, { ifNoneMatch: entry.ifNoneMatch, range: entry.range }, this._resourceLoadingCts.token); } catch (e) { this._send('did-load-resource', { id: entry.id, @@ -760,25 +774,92 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi } } - private async loadResource(id: number, uri: URI, ifNoneMatch: string | undefined) { + 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, { - ifNoneMatch, + ifNoneMatch: options.ifNoneMatch, roots: this._content.options.localResourceRoots || [], - }, this._uriIdentityService, this._fileService, this._logService, this._resourceLoadingCts.token); + range: options.range, + }, this._uriIdentityService, this._fileService, this._logService, token); switch (result.type) { case WebviewResourceResponse.Type.Success: { - const buffer = await this.streamToBuffer(result.stream); - return this._send('did-load-resource', { - id, - status: 200, - path: uri.path, - mime: result.mimeType, - data: buffer, - etag: result.etag, - mtime: result.mtime - }, [buffer]); + const range = options.range; + const requestedRangeEnd = range?.end !== undefined ? range.end : result.size - 1; + const rangeEnd = Math.min(requestedRangeEnd, result.size - 1); + const rangeHeader = range + ? `bytes ${range.start}-${rangeEnd}/${result.size}` + : undefined; + if (WebviewElement._supportsTransferableStreams.value) { + const stream = new ReadableStream>({ + start: (controller) => { + let closed = false; + const close = () => { + if (!closed) { + closed = true; + try { controller.close(); } catch { /* already closed */ } + cancellationSub.dispose(); + } + }; + const cancellationSub = token.onCancellationRequested(close); + + listenStream(result.stream, { + onData: (chunk) => { + if (!closed) { + try { + controller.enqueue(new Uint8Array(chunk.buffer.buffer as ArrayBuffer, chunk.buffer.byteOffset, chunk.buffer.byteLength)); + } catch { + closed = true; + cancellationSub.dispose(); + } + } + }, + onError: (err) => { + if (!closed) { + closed = true; + try { controller.error(err); } catch { /* already closed */ } + cancellationSub.dispose(); + } + }, + onEnd: () => close() + }, token); + } + }); + this._send('did-load-resource', { + id, + status: range ? 206 : 200, + path: uri.path, + mime: result.mimeType, + etag: result.etag, + mtime: result.mtime, + range: rangeHeader, + stream, + }, [stream]); + } else { + // Safari: transferable streams not supported, fall back to chunk messages + this._send('did-load-resource', { + id, + status: range ? 206 : 200, + path: uri.path, + mime: result.mimeType, + etag: result.etag, + mtime: result.mtime, + range: rangeHeader, + }); + listenStream(result.stream, { + onData: (chunk) => { + const data = new Uint8Array(chunk.buffer.buffer, chunk.buffer.byteOffset, chunk.buffer.byteLength); + this._send('did-load-resource-chunk', { id, data }, [data.buffer]); + }, + onError: () => { + this._send('did-load-resource-end', { id, error: true }); + }, + onEnd: () => { + this._send('did-load-resource-end', { id }); + } + }, token); + } + return; } case WebviewResourceResponse.Type.NotModified: { return this._send('did-load-resource', { @@ -808,11 +889,6 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi }); } - protected async streamToBuffer(stream: VSBufferReadableStream): Promise { - const vsBuffer = await streamToBuffer(stream); - return vsBuffer.buffer.buffer; - } - private async localLocalhost(id: string, origin: string) { const authority = this._environmentService.remoteAuthority; const resolveAuthority = authority ? await this._remoteAuthorityResolverService.resolveAuthority(authority) : undefined; diff --git a/src/vs/workbench/contrib/webview/browser/webviewMessages.d.ts b/src/vs/workbench/contrib/webview/browser/webviewMessages.d.ts index f7f6f8421c54d..339772905578c 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewMessages.d.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewMessages.d.ts @@ -31,7 +31,7 @@ export type FromWebviewMessage = { 'did-find': { didFind: boolean }; 'do-update-state': string; 'do-reload': void; - 'load-resource': { id: number; path: string; query: string; scheme: string; authority: string; ifNoneMatch?: string }; + 'load-resource': { id: number; path: string; query: string; scheme: string; authority: string; ifNoneMatch?: string; range?: { readonly start: number; readonly end?: number } }; 'load-localhost': { id: string; origin: string }; 'did-scroll-wheel': IMouseWheelEvent; 'fatal-error': { message: string }; @@ -64,8 +64,11 @@ export type ToWebviewMessage = { 'did-load-resource': | { id: number; status: 401 | 404; path: string } | { id: number; status: 304; path: string; mime: string; mtime: number | undefined } - | { id: number; status: 200; path: string; mime: string; data: any; etag: string | undefined; mtime: number | undefined } + | { id: number; status: 200 | 206; path: string; mime: string; etag: string | undefined; mtime: number | undefined; range?: string; stream?: ReadableStream } ; + // Safari fallback: transferable streams not supported + 'did-load-resource-chunk': { id: number; data: Uint8Array }; + 'did-load-resource-end': { id: number; error?: boolean }; 'did-load-localhost': { id: string; origin: string; diff --git a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts index deaf633184c3a..59013dfd3d418 100644 --- a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts @@ -4,9 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Delayer } from '../../../../base/common/async.js'; -import { VSBuffer, VSBufferReadableStream } from '../../../../base/common/buffer.js'; import { Schemas } from '../../../../base/common/network.js'; -import { consumeStream } from '../../../../base/common/stream.js'; 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'; @@ -92,22 +90,6 @@ export class ElectronWebviewElement extends WebviewElement { return `${Schemas.vscodeWebview}://${iframeId}`; } - protected override streamToBuffer(stream: VSBufferReadableStream): Promise { - // Join buffers from stream without using the Node.js backing pool. - // This lets us transfer the resulting buffer to the webview. - return consumeStream(stream, (buffers: readonly VSBuffer[]) => { - const totalLength = buffers.reduce((prev, curr) => prev + curr.byteLength, 0); - const ret = new ArrayBuffer(totalLength); - const view = new Uint8Array(ret); - let offset = 0; - for (const element of buffers) { - view.set(element.buffer, offset); - offset += element.byteLength; - } - return ret; - }); - } - /** * Webviews expose a stateful find API. * Successive calls to find will move forward or backward through onFindResults diff --git a/src/vs/workbench/services/environment/browser/environmentService.ts b/src/vs/workbench/services/environment/browser/environmentService.ts index 83d722c7e6ca4..d9e0aeacc107c 100644 --- a/src/vs/workbench/services/environment/browser/environmentService.ts +++ b/src/vs/workbench/services/environment/browser/environmentService.ts @@ -113,6 +113,9 @@ export class BrowserWorkbenchEnvironmentService implements IBrowserWorkbenchEnvi @memoize get workspaceStorageHome(): URI { return joinPath(this.userRoamingDataHome, 'workspaceStorage'); } + @memoize + get appSharedDataHome(): URI { return joinPath(this.userRoamingDataHome, 'sharedData'); } + @memoize get localHistoryHome(): URI { return joinPath(this.userRoamingDataHome, 'History'); } diff --git a/src/vs/workbench/services/storage/browser/storageService.ts b/src/vs/workbench/services/storage/browser/storageService.ts index 309033e3b9a36..f055925b0c1e1 100644 --- a/src/vs/workbench/services/storage/browser/storageService.ts +++ b/src/vs/workbench/services/storage/browser/storageService.ts @@ -27,6 +27,9 @@ export class BrowserStorageService extends AbstractStorageService { private applicationStorageDatabase: IIndexedDBStorageDatabase | undefined; private readonly applicationStoragePromise = new DeferredPromise<{ indexedDb: IIndexedDBStorageDatabase; storage: IStorage }>(); + private applicationSharedStorage: IStorage | undefined; + private applicationSharedStorageDatabase: IIndexedDBStorageDatabase | undefined; + private profileStorage: IStorage | undefined; private profileStorageDatabase: IIndexedDBStorageDatabase | undefined; private profileStorageProfile: IUserDataProfile; @@ -38,6 +41,7 @@ export class BrowserStorageService extends AbstractStorageService { get hasPendingUpdate(): boolean { return Boolean( this.applicationStorageDatabase?.hasPendingUpdate || + this.applicationSharedStorageDatabase?.hasPendingUpdate || this.profileStorageDatabase?.hasPendingUpdate || this.workspaceStorageDatabase?.hasPendingUpdate ); @@ -64,6 +68,7 @@ export class BrowserStorageService extends AbstractStorageService { // Init storages await Promises.settled([ this.createApplicationStorage(), + this.createApplicationSharedStorage(), this.createProfileStorage(this.profileStorageProfile), this.createWorkspaceStorage() ]); @@ -84,6 +89,19 @@ export class BrowserStorageService extends AbstractStorageService { this.applicationStoragePromise.complete({ indexedDb: applicationStorageIndexedDB, storage: this.applicationStorage }); } + private async createApplicationSharedStorage(): Promise { + const applicationSharedStorageIndexedDB = await IndexedDBStorageDatabase.createApplicationSharedStorage(this.logService); + + this.applicationSharedStorageDatabase = this._register(applicationSharedStorageIndexedDB); + this.applicationSharedStorage = this._register(new Storage(this.applicationSharedStorageDatabase)); + + this._register(this.applicationSharedStorage.onDidChangeStorage(e => this.emitDidChangeValue(StorageScope.APPLICATION_SHARED, e))); + + await this.applicationSharedStorage.init(); + + this.updateIsNew(this.applicationSharedStorage); + } + private async createProfileStorage(profile: IUserDataProfile): Promise { // First clear any previously associated disposables @@ -143,6 +161,8 @@ export class BrowserStorageService extends AbstractStorageService { protected getStorage(scope: StorageScope): IStorage | undefined { switch (scope) { + case StorageScope.APPLICATION_SHARED: + return this.applicationSharedStorage; case StorageScope.APPLICATION: return this.applicationStorage; case StorageScope.PROFILE: @@ -154,6 +174,8 @@ export class BrowserStorageService extends AbstractStorageService { protected getLogDetails(scope: StorageScope): string | undefined { switch (scope) { + case StorageScope.APPLICATION_SHARED: + return this.applicationSharedStorageDatabase?.name; case StorageScope.APPLICATION: return this.applicationStorageDatabase?.name; case StorageScope.PROFILE: @@ -212,6 +234,7 @@ export class BrowserStorageService extends AbstractStorageService { // we expect data to be written when the unload happens. if (isSafari) { this.applicationStorage?.close(); + this.applicationSharedStorageDatabase?.close(); this.profileStorageDatabase?.close(); this.workspaceStorageDatabase?.close(); } @@ -224,7 +247,7 @@ export class BrowserStorageService extends AbstractStorageService { async clear(): Promise { // Clear key/values - for (const scope of [StorageScope.APPLICATION, StorageScope.PROFILE, StorageScope.WORKSPACE]) { + for (const scope of [StorageScope.APPLICATION, StorageScope.APPLICATION_SHARED, StorageScope.PROFILE, StorageScope.WORKSPACE]) { for (const target of [StorageTarget.USER, StorageTarget.MACHINE]) { for (const key of this.keys(scope, target)) { this.remove(key, scope); @@ -237,6 +260,7 @@ export class BrowserStorageService extends AbstractStorageService { // Clear databases await Promises.settled([ this.applicationStorageDatabase?.clear() ?? Promise.resolve(), + this.applicationSharedStorageDatabase?.clear() ?? Promise.resolve(), this.profileStorageDatabase?.clear() ?? Promise.resolve(), this.workspaceStorageDatabase?.clear() ?? Promise.resolve() ]); @@ -295,6 +319,10 @@ export class IndexedDBStorageDatabase extends Disposable implements IIndexedDBSt return IndexedDBStorageDatabase.create({ id: 'global', broadcastChanges: true }, logService); } + static async createApplicationSharedStorage(logService: ILogService): Promise { + return IndexedDBStorageDatabase.create({ id: 'global-shared', broadcastChanges: true }, logService); + } + static async createProfileStorage(profile: IUserDataProfile, logService: ILogService): Promise { return IndexedDBStorageDatabase.create({ id: `global-${profile.id}`, broadcastChanges: true }, logService); } diff --git a/src/vs/workbench/services/storage/electron-browser/storageService.ts b/src/vs/workbench/services/storage/electron-browser/storageService.ts index f45b3ecdad524..a5a726c538b02 100644 --- a/src/vs/workbench/services/storage/electron-browser/storageService.ts +++ b/src/vs/workbench/services/storage/electron-browser/storageService.ts @@ -3,12 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; +import { IStorage, Storage, MigratingStorage } from '../../../../base/parts/storage/common/storage.js'; import { RemoteStorageService } from '../../../../platform/storage/common/storageService.js'; +import { FallbackApplicationStorageDatabaseClient, ApplicationSharedStorageDatabaseClient } from '../../../../platform/storage/common/storageIpc.js'; +import { StorageScope } from '../../../../platform/storage/common/storage.js'; import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js'; import { IAnyWorkspaceIdentifier } from '../../../../platform/workspace/common/workspace.js'; import { IUserDataProfileService } from '../../userDataProfile/common/userDataProfile.js'; +import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js'; export class NativeWorkbenchStorageService extends RemoteStorageService { @@ -17,14 +20,44 @@ export class NativeWorkbenchStorageService extends RemoteStorageService { private readonly userDataProfileService: IUserDataProfileService, userDataProfilesService: IUserDataProfilesService, mainProcessService: IMainProcessService, - environmentService: IEnvironmentService + private readonly workbenchEnvironmentService: IWorkbenchEnvironmentService, ) { - super(workspace, { currentProfile: userDataProfileService.currentProfile, defaultProfile: userDataProfilesService.defaultProfile }, mainProcessService, environmentService); + super(workspace, { currentProfile: userDataProfileService.currentProfile, defaultProfile: userDataProfilesService.defaultProfile }, mainProcessService, workbenchEnvironmentService); this.registerListeners(); } + protected override createApplicationSharedStorage(): IStorage { + const channel = this.remoteService.getChannel('storage'); + const storageDataBaseClient = this._register(new ApplicationSharedStorageDatabaseClient(channel)); + const applicationSharedStorage = this._register(new MigratingStorage(storageDataBaseClient)); + this._register(applicationSharedStorage.onDidChangeStorage(e => this.emitDidChangeValue(StorageScope.APPLICATION_SHARED, e))); + return applicationSharedStorage; + } + + protected override async doInitialize(): Promise { + await super.doInitialize(); + const applicationSharedStorage = this.getStorage(StorageScope.APPLICATION_SHARED); + if (applicationSharedStorage instanceof MigratingStorage) { + // Fall back to APPLICATION storage for transparent + // migration of keys moved to APPLICATION_SHARED scope. On hit, values + // are automatically written through to the shared storage. + let applicationSharedFallbackStorage; + if (this.workbenchEnvironmentService.isSessionsWindow) { + const channel = this.remoteService.getChannel('storage'); + applicationSharedFallbackStorage = this._register(new Storage(this._register(new FallbackApplicationStorageDatabaseClient(channel)))); + await applicationSharedFallbackStorage.init(); + } else { + applicationSharedFallbackStorage = this.getStorage(StorageScope.APPLICATION); + } + if (applicationSharedFallbackStorage) { + applicationSharedStorage.setFallbackStorage(applicationSharedFallbackStorage, this.workbenchEnvironmentService.isSessionsWindow); + } + } + } + private registerListeners(): void { this._register(this.userDataProfileService.onDidChangeCurrentProfile(e => e.join(this.switchToProfile(e.profile)))); } } + diff --git a/src/vs/workbench/services/workspaces/common/workspaceTrust.ts b/src/vs/workbench/services/workspaces/common/workspaceTrust.ts index e349da943d397..ded97bb4bcfa6 100644 --- a/src/vs/workbench/services/workspaces/common/workspaceTrust.ts +++ b/src/vs/workbench/services/workspaces/common/workspaceTrust.ts @@ -188,7 +188,7 @@ export class WorkspaceTrustManagementService extends Disposable implements IWork private registerListeners(): void { this._register(this.workspaceService.onDidChangeWorkspaceFolders(async () => await this.updateWorkspaceTrust())); - this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION, this.storageKey, this._store)(async () => { + this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION_SHARED, this.storageKey, this._store)(async () => { /* This will only execute if storage was changed by a user action in a separate window */ if (JSON.stringify(this._trustStateInfo) !== JSON.stringify(this.loadTrustInfo())) { this._trustStateInfo = this.loadTrustInfo(); @@ -249,7 +249,7 @@ export class WorkspaceTrustManagementService extends Disposable implements IWork } private loadTrustInfo(): IWorkspaceTrustInfo { - const infoAsString = this.storageService.get(this.storageKey, StorageScope.APPLICATION); + const infoAsString = this.storageService.get(this.storageKey, StorageScope.APPLICATION_SHARED); let result: IWorkspaceTrustInfo | undefined; try { @@ -275,7 +275,7 @@ export class WorkspaceTrustManagementService extends Disposable implements IWork } private async saveTrustInfo(): Promise { - this.storageService.store(this.storageKey, JSON.stringify(this._trustStateInfo), StorageScope.APPLICATION, StorageTarget.MACHINE); + this.storageService.store(this.storageKey, JSON.stringify(this._trustStateInfo), StorageScope.APPLICATION_SHARED, StorageTarget.MACHINE); this._onDidChangeTrustedFolders.fire(); await this.updateWorkspaceTrust(); diff --git a/src/vs/workbench/services/workspaces/test/common/workspaceTrust.test.ts b/src/vs/workbench/services/workspaces/test/common/workspaceTrust.test.ts index 9463527ef126b..d9699c521db65 100644 --- a/src/vs/workbench/services/workspaces/test/common/workspaceTrust.test.ts +++ b/src/vs/workbench/services/workspaces/test/common/workspaceTrust.test.ts @@ -108,7 +108,7 @@ suite('Workspace Trust', () => { test('empty workspace - trusted, open trusted file', async () => { await configurationService.setUserConfiguration('security', getUserSettings(true, true)); const trustInfo: IWorkspaceTrustInfo = { uriTrustInfo: [{ uri: URI.parse('file:///Folder'), trusted: true }] }; - storageService.store(WORKSPACE_TRUST_STORAGE_KEY, JSON.stringify(trustInfo), StorageScope.APPLICATION, StorageTarget.MACHINE); + storageService.store(WORKSPACE_TRUST_STORAGE_KEY, JSON.stringify(trustInfo), StorageScope.APPLICATION_SHARED, StorageTarget.MACHINE); environmentService.filesToOpenOrCreate = [{ fileUri: URI.parse('file:///Folder/file.txt') }]; instantiationService.stub(IWorkbenchEnvironmentService, { ...environmentService }); diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts index c9c55a2e32330..b22dea113a28d 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts @@ -377,11 +377,11 @@ function renderInlineChatZoneWidget({ container, disposableStore, theme }: Compo export default defineThemedFixtureGroup({ path: 'editor/' }, { InlineChatZoneWidget: defineComponentFixture({ - labels: { kind: 'screenshot' }, + labels: { kind: 'screenshot', blocksCi: true }, render: (context) => renderInlineChatZoneWidget(context, false), }), InlineChatZoneWidgetTerminated: defineComponentFixture({ - labels: { kind: 'screenshot' }, + labels: { kind: 'screenshot', blocksCi: true }, render: (context) => renderInlineChatZoneWidget(context, true), }), }); diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index cd0f0dee433a4..c0be8d3ce37d0 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -74,7 +74,7 @@ import { INotificationService } from '../../../../platform/notification/common/n import { TestNotificationService } from '../../../../platform/notification/test/common/testNotificationService.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { NullOpenerService } from '../../../../platform/opener/test/common/nullOpenerService.js'; -import { IApplicationStorageValueChangeEvent, IProfileStorageValueChangeEvent, IStorageEntry, IStorageService, IStorageTargetChangeEvent, IStorageValueChangeEvent, IWillSaveStateEvent, IWorkspaceStorageValueChangeEvent, StorageScope, StorageTarget, WillSaveStateReason } from '../../../../platform/storage/common/storage.js'; +import { IApplicationSharedStorageValueChangeEvent, IApplicationStorageValueChangeEvent, IProfileStorageValueChangeEvent, IStorageEntry, IStorageService, IStorageTargetChangeEvent, IStorageValueChangeEvent, IWillSaveStateEvent, IWorkspaceStorageValueChangeEvent, StorageScope, StorageTarget, WillSaveStateReason } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { NullTelemetryServiceShape } from '../../../../platform/telemetry/common/telemetryUtils.js'; import { TestThemeService } from '../../../../platform/theme/test/common/testThemeService.js'; @@ -121,6 +121,7 @@ class NullStorageService implements IStorageService { onDidChangeValue(scope: StorageScope.WORKSPACE, key: string | undefined, disposable: DisposableStore): Event; onDidChangeValue(scope: StorageScope.PROFILE, key: string | undefined, disposable: DisposableStore): Event; onDidChangeValue(scope: StorageScope.APPLICATION, key: string | undefined, disposable: DisposableStore): Event; + onDidChangeValue(scope: StorageScope.APPLICATION_SHARED, key: string | undefined, disposable: DisposableStore): Event; onDidChangeValue(scope: StorageScope, key: string | undefined, disposable: DisposableStore): Event { return Event.filter(this._onDidChangeValue.event, e => e.scope === scope && (key === undefined || e.key === key), disposable); } diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index dd3b5ec6fd273..32c0e5bc34c3b 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -50,6 +50,7 @@ import './browser/parts/editor/editorParts.js'; import './browser/parts/paneCompositePartService.js'; import './browser/parts/banner/bannerPart.js'; import './browser/parts/statusbar/statusbarPart.js'; +import './browser/parts/titlebar/menubar.contribution.js'; //#endregion @@ -410,6 +411,7 @@ import './contrib/codeActions/browser/codeActions.contribution.js'; // Timeline import './contrib/timeline/browser/timeline.contribution.js'; +import './contrib/timeline/browser/timeline.service.contribution.js'; // Local History import './contrib/localHistory/browser/localHistory.contribution.js'; diff --git a/src/vs/workbench/workbench.desktop.main.ts b/src/vs/workbench/workbench.desktop.main.ts index 4e4b802d3e16d..4277093b10389 100644 --- a/src/vs/workbench/workbench.desktop.main.ts +++ b/src/vs/workbench/workbench.desktop.main.ts @@ -125,6 +125,7 @@ import './contrib/debug/electron-browser/extensionHostDebugService.js'; // Extensions Management import './contrib/extensions/electron-browser/extensions.contribution.js'; +import './contrib/extensions/electron-browser/devtoolsExtensionHost.contribution.js'; // Issues import './contrib/issue/electron-browser/issue.contribution.js'; @@ -177,6 +178,8 @@ import './contrib/multiDiffEditor/browser/multiDiffEditor.contribution.js'; // Remote Tunnel import './contrib/remoteTunnel/electron-browser/remoteTunnel.contribution.js'; +// Chat +import './contrib/chat/electron-browser/chat.contribution.js'; // Encryption import './contrib/encryption/electron-browser/encryption.contribution.js'; diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 6a77f7103e116..7da2bff277f15 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -5,3 +5,15 @@ #### editor/codeEditor/CodeEditor/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/42624fbba5e0db7f32c224b5eb9c5dd3b08245697ae2e7d2a88be0d7c287129b) + +#### editor/inlineChatZoneWidget/InlineChatZoneWidget/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/7d1a6d2346754115e77fc2b0b09a0e6fb6fd9fe22acbff6354813eefb8b45fc2) + +#### editor/inlineChatZoneWidget/InlineChatZoneWidget/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/11dbc075c584b7dde0f08314e98db121e420529ced6249effb941cfe2ae3164b) + +#### editor/inlineChatZoneWidget/InlineChatZoneWidgetTerminated/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/2fbc12507b59ff950d9612d2df92e6b39d8bf0bf500478e42eca2ead4d1ae206) + +#### editor/inlineChatZoneWidget/InlineChatZoneWidgetTerminated/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/4632ab04d1fdd7db9ab0e00cce10aefb7a6344eb8869dfce740309a8801cab73)