From 7c2a0f6c91ac92565dde082cce13bd2eb251b8b2 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Mon, 18 May 2026 23:22:36 -0700 Subject: [PATCH 01/10] Claude: resume cross-window sessions from disk on first send (#317248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Claude: resume cross-window sessions from disk on first send Repro: open a Claude session in window A, then in window B (or after an agent-host restart) send a message to that session. Before: sendMessage hit the 'session not in _sessions and not provisional' branch and threw 'Cannot send to unknown session: ', because materialize was only ever reachable via the createSession provisional path. After: that branch routes through new _resumeSession(sessionId, uri), which mirrors CopilotAgent._resumeSession — reads workingDirectory from sdkService.getSessionInfo, model + permissionMode from the metadata overlay, synthesises an IClaudeMaterializeProvisional, and calls materializer.materialize(..., 'resume') so the SDK reloads the existing transcript instead of minting a fresh sessionId. ClaudeMaterializer.materialize gains an optional startMode parameter ('fresh' | 'resume', default 'fresh') that selects Options.sessionId vs Options.resume in _buildOptions. The existing fresh-startup call sites are unchanged. Tests: existing 'disposing a provisional session never calls SDK startup' assertion still passes — the new error wording 'Cannot resume unknown session' still matches /unknown session/i, and _resumeSession throws before startup when the fake SDK has no session record. * Test: cross-window Claude session resume + missing-SDK-record Two unit tests for the _resumeSession path added in the previous commit: 1. Disk-only happy path: stage an entry in sdk.sessionList that was never createSession'd on this agent, call sendMessage on its URI, and pin that the SDK is started with Options.resume = sessionId (not Options.sessionId), the materialize event fires with the right cwd, and the session lands in _sessions. 2. Failure path: sdk.sessionList is empty, sendMessage on an unknown URI throws /unknown session/i and does NOT spawn the SDK subprocess (startupCallCount stays 0). * Claude resume: resolve git project from cwd, mirror createSession _resumeSession was passing project: undefined into both the provisional record and the materialize event. The fresh-session path in createSession resolves project metadata from the workingDirectory via projectFromCopilotContext + IAgentHostGitService; resume should do the same so the materialize event consumers see the same project shape regardless of which window opened the session. Best-effort: a resolution failure (no repo, git CLI missing) downgrades to undefined rather than blocking the resume. * Claude: don't clobber resumed permissionMode on turn 2 Copilot PR review on cross-window resume PR caught a real regression: the materialized-session branch in sendMessage was unconditionally calling: session.setPermissionMode(_readSessionPermissionMode(uri) ?? 'default') before yielding every non-first turn. For a session resurfaced via _resumeSession, AgentService never registers the per-session schema (that side-effect only fires for createSession-spawned sessions), so _readSessionPermissionMode returns undefined and the fallback silently downgrades a plan-mode session to default mid-conversation. Fix: only forward setPermissionMode when the live config carries a defined value. The session's seeded bijective state (set via seedBijectiveState at resume time) stays authoritative when there is no live override. Test: stage the per-session DB with claude.permissionMode='plan', resume, send two turns, assert no 'default' ever lands at the SDK (recordedModes stays ['plan']). createTestContext gains an optional { database } override so the test can pre-populate the overlay without rebuilding the service collection inline. --- .../agentHost/node/claude/claudeAgent.ts | 119 +++++++++++++- .../node/claude/claudeMaterializer.ts | 6 +- .../agentHost/node/claude/phase8.5-plan.md | 2 +- .../platform/agentHost/node/claude/roadmap.md | 2 +- .../agentHost/test/node/claudeAgent.test.ts | 148 +++++++++++++++++- 5 files changed, 264 insertions(+), 13 deletions(-) diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index 32dfa1e8c8bf3..774d33af99742 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -35,7 +35,7 @@ import { mapSessionMessagesToTurns } from './claudeReplayMapper.js'; import { getSubagentTranscript } from './claudeSubagentResolver.js'; import { ClaudeAgentSession } from './claudeAgentSession.js'; import { handleCanUseTool } from './claudeCanUseTool.js'; -import { ClaudeMaterializer } from './claudeMaterializer.js'; +import { ClaudeMaterializer, type IClaudeMaterializeProvisional } from './claudeMaterializer.js'; import { tryParseClaudeModelId } from './claudeModelId.js'; import { resolvePromptToContentBlocks } from './claudePromptResolver.js'; import { IClaudeProxyHandle, IClaudeProxyService } from './claudeProxyService.js'; @@ -543,6 +543,93 @@ export class ClaudeAgent extends Disposable implements IAgent { return session; } + /** + * Bring up a session whose state exists only on disk — created in + * another window, or before an agent-host restart. Mirror of + * `CopilotAgent._resumeSession`. Reads `workingDirectory` from the + * SDK's session record and `model` / `permissionMode` from the + * metadata overlay, builds an {@link IClaudeMaterializeProvisional} + * record on the fly, and routes through + * {@link ClaudeMaterializer.materialize} with `startMode: 'resume'` + * so the SDK reloads the existing transcript instead of minting a + * fresh one. + * + * Caller must hold the session sequencer so two concurrent + * `sendMessage` calls for a freshly-resumed session collapse into + * one resume + two ordered sends. + */ + private async _resumeSession(sessionId: string, sessionUri: URI): Promise { + this._logService.info(`[Claude:${sessionId}] _resumeSession — no in-memory state, rebuilding from disk`); + const proxyHandle = this._ensureAuthenticated(); + const sdkInfo = await this._sdkService.getSessionInfo(sessionId); + if (!sdkInfo) { + throw new Error(`Cannot resume unknown session: ${sessionId} (not present in SDK transcript store)`); + } + const workingDirectory = sdkInfo.cwd ? URI.file(sdkInfo.cwd) : undefined; + if (!workingDirectory) { + throw new Error(`Cannot resume session ${sessionId}: workingDirectory missing from SDK transcript`); + } + let overlay: IClaudeSessionOverlay = {}; + try { + overlay = await this._metadataStore.read(sessionUri); + } catch (err) { + this._logService.warn(`[Claude:${sessionId}] overlay read failed during resume; continuing with defaults`, err); + } + const permissionMode = this._readSessionPermissionMode(sessionUri) + ?? overlay.permissionMode + ?? 'default'; + // Resolve git project metadata from the resumed cwd, same as + // createSession's non-fork path. Best-effort: a failure (no + // repo, git CLI missing, etc.) downgrades to `undefined` rather + // than blocking the resume. + let project: IAgentSessionProjectInfo | undefined; + try { + project = await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); + } catch (err) { + this._logService.warn(`[Claude:${sessionId}] project resolution failed during resume; continuing without project`, err); + } + const abortController = new AbortController(); + const provisional: IClaudeMaterializeProvisional = { + sessionId, + sessionUri, + workingDirectory, + abortController, + project, + model: overlay.model, + }; + + const canUseTool: NonNullable = (toolName, input, options) => + handleCanUseTool( + { getSession: id => this._sessions.get(id)?.session, configurationService: this._configurationService }, + sessionId, toolName, input, options, + ); + + const session = await this._materializer.materialize(provisional, proxyHandle, permissionMode, canUseTool, 'resume'); + + const initialEffort = clampEffortForRuntime(resolveClaudeEffort(overlay.model)); + session.seedBijectiveState({ + model: overlay.model?.id, + effort: initialEffort, + permissionMode, + }); + session.attachRematerializer(async (_reason) => { + const liveMode = this._readSessionPermissionMode(sessionUri) ?? permissionMode; + return this._materializer.materializeResume(provisional, proxyHandle, liveMode, canUseTool); + }); + + const entry = new ClaudeSessionEntry(session); + entry.addDisposable(session.onDidSessionProgress(signal => this._onDidSessionProgress.fire(signal))); + this._sessions.set(sessionId, entry); + + this._onDidMaterializeSession.fire({ + session: sessionUri, + workingDirectory, + project, + }); + + return session; + } + /** * Pull `permissionMode` out of the post-validation `IAgentCreateSessionConfig.config` * bag, narrowing the runtime `unknown` value to the SDK's six-value @@ -835,19 +922,37 @@ export class ClaudeAgent extends Disposable implements IAgent { return this._sessionSequencer.queue(sessionId, async () => { let session = this._sessions.get(sessionId)?.session; if (!session) { - if (!this._provisionalSessions.has(sessionId)) { - throw new Error(`Cannot send to unknown session: ${sessionId}`); + if (this._provisionalSessions.has(sessionId)) { + // Materialize seeds permissionMode via Options.permissionMode, + // so no setPermissionMode call needed on this turn. + session = await this._materializeProvisional(sessionId); + } else { + // Session exists on disk (created in another window or + // before agent restart) but has no in-memory state in + // this agent instance. Reconstruct a provisional record + // from the SDK transcript + metadata overlay and bring + // it up under `Options.resume` so the SDK reloads the + // existing history rather than minting a fresh one. + session = await this._resumeSession(sessionId, sessionUri); } - // Materialize seeds permissionMode via Options.permissionMode, - // so no setPermissionMode call needed on this turn. - session = await this._materializeProvisional(sessionId); } else { // Plan S3.6: forward live `permissionMode` to the bound // `Query` immediately before yielding the next user message // so a `SessionConfigChanged` action that arrived between // turns wins. Awaited so the SDK has acknowledged the mode // change before `session.send(...)` yields the next prompt. - await session.setPermissionMode(this._readSessionPermissionMode(sessionUri) ?? 'default'); + // + // Only call when the config carries an actual value: + // `_readSessionPermissionMode` returns `undefined` when + // the session's schema hasn't been registered (e.g. a + // cross-window resume that bypassed AgentService's + // schema-registration path), and falling back to + // `'default'` here would silently overwrite the + // session's seeded bijective state with the wrong mode. + const liveMode = this._readSessionPermissionMode(sessionUri); + if (liveMode !== undefined) { + await session.setPermissionMode(liveMode); + } } const contentBlocks = resolvePromptToContentBlocks(prompt, attachments); diff --git a/src/vs/platform/agentHost/node/claude/claudeMaterializer.ts b/src/vs/platform/agentHost/node/claude/claudeMaterializer.ts index 98289b06e9697..cb7a4ea23cece 100644 --- a/src/vs/platform/agentHost/node/claude/claudeMaterializer.ts +++ b/src/vs/platform/agentHost/node/claude/claudeMaterializer.ts @@ -70,17 +70,19 @@ export class ClaudeMaterializer { proxyHandle: IClaudeProxyHandle, permissionMode: ClaudePermissionMode, canUseTool: NonNullable, + startMode: 'fresh' | 'resume' = 'fresh', ): Promise { if (!provisional.workingDirectory) { throw new Error(`Cannot materialize Claude session ${provisional.sessionId}: workingDirectory is required`); } - const options = await this._buildOptions(provisional, proxyHandle, permissionMode, canUseTool, false); + const isResume = startMode === 'resume'; + const options = await this._buildOptions(provisional, proxyHandle, permissionMode, canUseTool, isResume); // Trace what the SDK gets so live debugging doesn't have to infer // from the absence of a `fileEdit` block whether the edit-tracking // plumbing was wired this session. - this._logService.info(`[Claude] session ${provisional.sessionId}: enableFileCheckpointing=${options.enableFileCheckpointing} startMode=fresh`); + this._logService.info(`[Claude] session ${provisional.sessionId}: enableFileCheckpointing=${options.enableFileCheckpointing} startMode=${startMode}`); const warm = await this._sdkService.startup({ options }); diff --git a/src/vs/platform/agentHost/node/claude/phase8.5-plan.md b/src/vs/platform/agentHost/node/claude/phase8.5-plan.md index 7061f51e24e53..cd49745fa5f3c 100644 --- a/src/vs/platform/agentHost/node/claude/phase8.5-plan.md +++ b/src/vs/platform/agentHost/node/claude/phase8.5-plan.md @@ -4,7 +4,7 @@ > Last updated: 2026-05-15 after 3-model council (GPT-5.5, Claude Opus 4.6, > GPT-5.3-Codex). Pre-grilling draft. -**Status:** implemented (E2E partially validated live; rest covered by unit tests) +**Status:** ✅ **DONE** (merged to main; E2E verified live) ## Goal diff --git a/src/vs/platform/agentHost/node/claude/roadmap.md b/src/vs/platform/agentHost/node/claude/roadmap.md index 2a0f7ca1fdf32..12be83be4788c 100644 --- a/src/vs/platform/agentHost/node/claude/roadmap.md +++ b/src/vs/platform/agentHost/node/claude/roadmap.md @@ -803,7 +803,7 @@ client-side accept of one and reject of the other behaves correctly. Exit criteria: file diffs render in the workbench; per-file accept/reject works. -### Phase 8.5 — Rich tool-call rendering parity with Copilot +### Phase 8.5 — Rich tool-call rendering parity with Copilot ✅ **DONE** Claude's tool-call cards today only carry the static display name from [`claudeToolDisplay.ts`](./claudeToolDisplay.ts) (`"Run shell command"`, diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 340375a73d0cc..d45901a897ea6 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -511,13 +511,17 @@ class CapturingLogService extends NullLogService { function createTestContext( disposables: Pick, - overrides?: { logService?: ILogService }, + overrides?: { logService?: ILogService; database?: TestSessionDatabase }, ): ITestContext { const proxy = new FakeClaudeProxyService(); const api = new FakeCopilotApiService(); api.models = async () => [...ALL_MODELS]; const sdk = new FakeClaudeAgentSdkService(); - const sessionData = new RecordingSessionDataService(createSessionDataService()); + const sessionData = new RecordingSessionDataService( + overrides?.database + ? createSessionDataService(overrides.database) + : createSessionDataService() + ); const logService = overrides?.logService ?? new NullLogService(); const stateManager = disposables.add(new AgentHostStateManager(logService)); const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); @@ -1859,6 +1863,146 @@ suite('ClaudeAgent', () => { }); }); + test('sendMessage on a disk-only session (created in another window) resumes from disk', async () => { + // Regression for: "Open a session that was not started in the + // active window, send it a message → Error: Cannot send to + // unknown session: ". Before the fix, sendMessage's else + // branch (no `_sessions` entry AND no `_provisionalSessions` + // entry) threw outright. After the fix it routes through + // `_resumeSession`, which mirrors CopilotAgent._resumeSession: + // read `cwd` from `sdkService.getSessionInfo`, model + permission + // mode from the metadata overlay, build an on-the-fly provisional + // record, and materialize with `startMode: 'resume'` so the SDK + // loads the existing transcript via `Options.resume` instead of + // minting a fresh sessionId via `Options.sessionId`. + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + // Stage a session that exists on disk (in the SDK's transcript + // store) but was never createSession'd on this agent instance. + const sessionId = 'cross-window-session-id'; + const sessionUri = AgentSession.uri('claude', sessionId); + sdk.sessionList = [{ + sessionId, + summary: 'From another window', + lastModified: 5000, + createdAt: 4900, + cwd: URI.file('/work').fsPath, + }]; + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), + makeResultSuccess(sessionId), + ]; + + const events: IAgentMaterializeSessionEvent[] = []; + disposables.add(agent.onDidMaterializeSession(e => events.push(e))); + + await agent.sendMessage(sessionUri, 'hi', undefined, 'turn-1'); + + assert.deepStrictEqual({ + startupCallCount: sdk.startupCallCount, + materializeEventCount: events.length, + eventSession: events[0]?.session.toString(), + eventCwd: events[0]?.workingDirectory?.fsPath, + startupOptionsCwd: sdk.capturedStartupOptions[0]?.cwd, + // In resume mode the SDK gets `Options.resume = ` and + // MUST NOT get `Options.sessionId`. + startupOptionsResume: sdk.capturedStartupOptions[0]?.resume, + startupOptionsSessionId: sdk.capturedStartupOptions[0]?.sessionId, + sessionInMap: agent.getSessionForTesting(sessionUri) !== undefined, + }, { + startupCallCount: 1, + materializeEventCount: 1, + eventSession: sessionUri.toString(), + eventCwd: URI.file('/work').fsPath, + startupOptionsCwd: URI.file('/work').fsPath, + startupOptionsResume: sessionId, + startupOptionsSessionId: undefined, + sessionInMap: true, + }); + }); + + test('sendMessage on a disk-only session whose SDK record is missing throws "unknown session"', async () => { + // Defense-in-depth pair to the resume-from-disk test above. If + // the SDK has no record of the session id at all (e.g. the + // transcript file was deleted out from under us), `_resumeSession` + // must surface a clear error rather than silently fabricating a + // fresh session bound to the wrong cwd. Also pins: no SDK startup + // is performed in this failure path (no subprocess spawn for a + // session we can't actually resume). + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const sessionUri = AgentSession.uri('claude', 'ghost-session-id'); + // sdk.sessionList stays empty — getSessionInfo resolves undefined. + + const sendErr = await agent.sendMessage(sessionUri, 'hi', undefined, 'turn-1') + .then(() => undefined, err => err); + + assert.deepStrictEqual({ + startupCallCount: sdk.startupCallCount, + sendThrewUnknown: sendErr instanceof Error && /unknown session/i.test(sendErr.message), + sessionAbsent: agent.getSessionForTesting(sessionUri) === undefined, + }, { + startupCallCount: 0, + sendThrewUnknown: true, + sessionAbsent: true, + }); + }); + + test('resumed session keeps overlay-derived permissionMode on turn 2 (no silent flip to default)', async () => { + // Regression for Copilot review feedback on the cross-window + // resume PR. Before the fix, the materialized-session branch in + // `sendMessage` unconditionally called + // `session.setPermissionMode(_readSessionPermissionMode(uri) ?? 'default')` + // on turn 2. For a cross-window-resumed session, AgentService + // never registered the per-session schema (that happens via + // `sessionAdded` for createSession-spawned sessions), so + // `_readSessionPermissionMode` returned `undefined`, the + // fallback `'default'` won, and a plan-mode session silently + // downgraded to default mode mid-conversation. + // + // The fix: only forward `setPermissionMode` when the live config + // actually carries a value, leaving the session's seeded + // bijective state (set via `seedBijectiveState` at resume time) + // authoritative otherwise. + // + // Setup: stage the per-session DB with `claude.permissionMode='plan'`, + // then run two turns. Turn 1 picks up the mode via + // `Options.permissionMode` at materialize. Turn 2 must NOT + // record an extra `setPermissionMode` call. + const sessionId = 'cross-window-mode-session'; + const sessionUri = AgentSession.uri('claude', sessionId); + + const db = new TestSessionDatabase(); + await db.setMetadata('claude.permissionMode', 'plan'); + + const { agent, sdk } = createTestContext(disposables, { database: db }); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + sdk.sessionList = [{ + sessionId, + summary: 'From another window (plan mode)', + lastModified: 5000, + createdAt: 4900, + cwd: URI.file('/work').fsPath, + }]; + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + await agent.sendMessage(sessionUri, 'turn-1', undefined, 't1'); + + sdk.nextQueryMessages = [makeResultSuccess(sessionId)]; + await agent.sendMessage(sessionUri, 'turn-2', undefined, 't2'); + + const fakeQuery = sdk.warmQueries.at(-1)?.produced; + assert.deepStrictEqual({ + optionsPermissionMode: sdk.capturedStartupOptions[0]?.permissionMode, + recordedModes: fakeQuery?.recordedPermissionModes ?? [], + }, { + optionsPermissionMode: 'plan', + recordedModes: ['plan'], + }); + }); + test('shutdown drains a mix of provisional and materialized sessions', async () => { // Phase 6 §5.1 Test 13. The shutdown spec is two-phase: // 1) Provisional sessions: abort each AbortController + clear From 990e9766080edfd2d99f604aba139ec730a90785 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Mon, 18 May 2026 23:52:13 -0700 Subject: [PATCH 02/10] sessions: temporarily disable WorktreeCreatedTaskDispatcher (#317254) Comments out the registration of WorktreeCreatedTaskDispatcher and removes its import to work around a bug. This is a stopgap until the underlying issue can be investigated and fixed. - Comments out the registerWorkbenchContribution2 call for WorktreeCreatedTaskDispatcher - Removes the now-unused import (Commit message generated by Copilot) --- src/vs/sessions/contrib/chat/browser/chat.contribution.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index 2fb07e229636b..c3c30af6edba7 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -23,7 +23,6 @@ import { KeybindingWeight } from '../../../../platform/keybinding/common/keybind import { ISessionsTasksService, SessionsTasksService } from './sessionsTasksService.js'; import { ISessionTaskRunnerRegistry, SessionTaskRunnerRegistry } from './sessionTaskRunner.js'; import { RegisterDefaultSessionTaskRunnersContribution } from './registerDefaultSessionTaskRunners.js'; -import { WorktreeCreatedTaskDispatcher } from './worktreeCreatedTaskDispatcher.js'; import { AgenticPromptsService } from './promptsService.js'; import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; @@ -150,7 +149,8 @@ registerWorkbenchContribution2(RegisterChatViewContainerContribution.ID, Registe registerWorkbenchContribution2(RunScriptContribution.ID, RunScriptContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(SessionsOpenerParticipantContribution.ID, SessionsOpenerParticipantContribution, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(RegisterDefaultSessionTaskRunnersContribution.ID, RegisterDefaultSessionTaskRunnersContribution, WorkbenchPhase.BlockStartup); -registerWorkbenchContribution2(WorktreeCreatedTaskDispatcher.ID, WorktreeCreatedTaskDispatcher, WorkbenchPhase.AfterRestored); +// todo@connor4312: temp until bugfix: +// registerWorkbenchContribution2(WorktreeCreatedTaskDispatcher.ID, WorktreeCreatedTaskDispatcher, WorkbenchPhase.AfterRestored); // register services registerSingleton(IPromptsService, AgenticPromptsService, InstantiationType.Delayed); From b8b5144cc6d926aa50402ca2511f3068a6bc7c54 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 19 May 2026 08:52:41 +0200 Subject: [PATCH 03/10] fix restoring a session on restart (#317166) --- .../services/sessions/browser/sessionsManagementService.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index ded674604e35c..7214f1d380a2a 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -686,7 +686,6 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } }; - disposables.add(this.onDidChangeSessions(() => tryRestore())); disposables.add(this.sessionsProvidersService.onDidChangeProviders(() => tryRestore())); // Call immediately in case the session became available between the From ff0e29286547e2fb0daef05491c2ade014bd8225 Mon Sep 17 00:00:00 2001 From: dileepyavan <52841896+dileepyavan@users.noreply.github.com> Date: Tue, 19 May 2026 01:07:00 -0700 Subject: [PATCH 04/10] Handle sandbox path resolution across platforms (#317198) --- .../sandbox/common/terminalSandboxEngine.ts | 43 +++++++--- .../test/common/terminalSandboxEngine.test.ts | 85 +++++++++++++++++++ 2 files changed, 118 insertions(+), 10 deletions(-) diff --git a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts index e4c5a4b2b0696..1d588558e47cf 100644 --- a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts +++ b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts @@ -520,15 +520,15 @@ export class TerminalSandboxEngine extends Disposable { let denyReadPaths: string[] = []; let denyWritePaths: string[] | undefined; if (this._os === OperatingSystem.Macintosh) { - allowWritePaths = this._updateAllowWritePathsWithWorkspaceFolders(macFileSystemSetting.allowWrite, commandRuntimeAllowWritePaths); - allowReadPaths = await this._updateAllowReadPathsWithAllowWrite(macFileSystemSetting.allowRead, allowWritePaths, commandRuntimeAllowReadPaths); - denyReadPaths = this._updateDenyReadPathsWithHome(macFileSystemSetting.denyRead); - denyWritePaths = macFileSystemSetting.denyWrite; + allowWritePaths = await this._resolveFileSystemPaths(this._updateAllowWritePathsWithWorkspaceFolders(macFileSystemSetting.allowWrite, commandRuntimeAllowWritePaths)); + allowReadPaths = await this._resolveFileSystemPaths(await this._updateAllowReadPathsWithAllowWrite(macFileSystemSetting.allowRead, allowWritePaths, commandRuntimeAllowReadPaths)); + denyReadPaths = await this._resolveFileSystemPaths(this._updateDenyReadPathsWithHome(macFileSystemSetting.denyRead)); + denyWritePaths = macFileSystemSetting.denyWrite ? await this._resolveFileSystemPaths(macFileSystemSetting.denyWrite) : undefined; } else if (this._os === OperatingSystem.Linux) { - allowWritePaths = this._resolveLinuxFileSystemPaths(this._updateAllowWritePathsWithWorkspaceFolders(linuxFileSystemSetting.allowWrite, commandRuntimeAllowWritePaths)); - allowReadPaths = this._resolveLinuxFileSystemPaths(await this._updateAllowReadPathsWithAllowWrite(linuxFileSystemSetting.allowRead, allowWritePaths, commandRuntimeAllowReadPaths)); - denyReadPaths = this._resolveLinuxFileSystemPaths(this._updateDenyReadPathsWithHome(linuxFileSystemSetting.denyRead)); - denyWritePaths = this._resolveLinuxFileSystemPaths(linuxFileSystemSetting.denyWrite); + allowWritePaths = await this._resolveFileSystemPaths(this._updateAllowWritePathsWithWorkspaceFolders(linuxFileSystemSetting.allowWrite, commandRuntimeAllowWritePaths)); + allowReadPaths = await this._resolveFileSystemPaths(await this._updateAllowReadPathsWithAllowWrite(linuxFileSystemSetting.allowRead, allowWritePaths, commandRuntimeAllowReadPaths)); + denyReadPaths = await this._resolveFileSystemPaths(this._updateDenyReadPathsWithHome(linuxFileSystemSetting.denyRead)); + denyWritePaths = await this._resolveFileSystemPaths(linuxFileSystemSetting.denyWrite); } const sandboxSettings = { network: allowNetwork ? { allowedDomains: [], deniedDomains: [], enabled: false } : this.getResolvedNetworkDomains(), @@ -617,8 +617,31 @@ export class TerminalSandboxEngine extends Disposable { return [...new Set([...(configuredAllowRead ?? []), ...getTerminalSandboxReadAllowListForCommands(this._os, this._commandAllowListKeywords, this._commandAllowListCommandDetails), ...commandRuntimeAllowRead, ...this._getSandboxRuntimeReadPaths(), ...await this._getWorkspaceStorageReadPaths(), ...allowWrite])]; } - private _resolveLinuxFileSystemPaths(paths: string[] | undefined): string[] { - return (paths ?? []).map(path => this._expandHomePath(path)); + private async _resolveFileSystemPaths(paths: string[] | undefined): Promise { + const resolvedPaths = await Promise.all((paths ?? []).map(path => this._resolveFileSystemPath(path))); + return [...new Set(resolvedPaths)]; + } + + private async _resolveFileSystemPath(path: string): Promise { + const expandedPath = this._os === OperatingSystem.Linux ? this._expandHomePath(path) : path; + if (!this._isAbsoluteFileSystemPath(expandedPath)) { + return expandedPath; + } + + try { + const realpath = await this._fileService.realpath(this._toFileSystemResource(expandedPath)); + return realpath?.path && realpath.path !== expandedPath ? realpath.path : expandedPath; + } catch { + return expandedPath; + } + } + + private _isAbsoluteFileSystemPath(path: string): boolean { + return (this._os === OperatingSystem.Windows ? win32 : posix).isAbsolute(path); + } + + private _toFileSystemResource(path: string): URI { + return this._userHome?.with({ path }) ?? this._tempDir?.with({ path }) ?? this._host.getWriteRoots()[0]?.with({ path }) ?? URI.file(path); } private _expandHomePath(path: string): string { diff --git a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts index b71f7450c54cc..a97970e715226 100644 --- a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts +++ b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts @@ -29,6 +29,17 @@ suite('TerminalSandboxEngine', () => { let createdFolders: string[]; class MockFileService { + private readonly _realpaths = new Map(); + + setRealpath(path: string, realpath: string): void { + this._realpaths.set(path, realpath); + } + + async realpath(uri: URI): Promise { + const realpath = this._realpaths.get(uri.path); + return realpath ? uri.with({ path: realpath }) : undefined; + } + async createFile(uri: URI, content: VSBuffer): Promise { createFileCount++; createdFiles.set(uri.path, content.toString()); @@ -125,6 +136,60 @@ suite('TerminalSandboxEngine', () => { ok(!config.filesystem.allowWrite.includes('/workspace-a'), 'Refreshed config should drop the old write root'); }); + test('resolves filesystem paths and expands home on Linux when writing the config', async () => { + configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxLinuxFileSystem, { + allowRead: ['~/read-link'], + allowWrite: ['/write-link'], + denyRead: ['~/deny-read-link'], + denyWrite: ['/deny-write-link'], + }); + fileService.setRealpath('/workspace-link', '/real/workspace'); + fileService.setRealpath('/write-link', '/real/write'); + fileService.setRealpath('/home/user/read-link', '/real/read'); + fileService.setRealpath('/home/user/deny-read-link', '/real/deny-read'); + fileService.setRealpath('/deny-write-link', '/real/deny-write'); + fileService.setRealpath('/home/user/.gnupg', '/real/gnupg'); + const host = createHost({ + getWriteRoots: () => [URI.file('/workspace-link')], + }); + const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); + + await engine.wrapCommand('git commit -S', false, undefined, undefined, [{ keyword: 'git', args: ['commit', '-S'] }]); + + const configPath = await engine.getSandboxConfigPath(); + ok(configPath, 'Config path should be defined'); + const config = JSON.parse(createdFiles.get(configPath)!); + ok(config.filesystem.allowWrite.includes('/real/workspace'), 'Workspace write root symlink should be resolved'); + ok(config.filesystem.allowWrite.includes('/real/write'), 'Configured allowWrite symlink should be resolved'); + ok(config.filesystem.allowRead.includes('/real/read'), 'Configured allowRead should expand ~ and resolve symlink'); + ok(config.filesystem.allowRead.includes('/real/gnupg'), 'Command runtime allowRead should expand ~ and resolve symlink'); + ok(config.filesystem.allowWrite.includes('/real/gnupg'), 'Command runtime allowWrite should expand ~ and resolve symlink'); + ok(config.filesystem.denyRead.includes('/real/deny-read'), 'Configured denyRead should expand ~ and resolve symlink'); + ok(config.filesystem.denyWrite.includes('/real/deny-write'), 'Configured denyWrite symlink should be resolved'); + }); + + test('keeps filesystem paths without symlinks when writing the config', async () => { + configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxLinuxFileSystem, { + allowRead: ['~/read-plain'], + allowWrite: ['/write-plain'], + denyRead: ['~/deny-read-plain'], + denyWrite: ['/deny-write-plain'], + }); + const host = createHost({ + getWriteRoots: () => [URI.file('/workspace-plain')], + }); + const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); + + const configPath = await engine.getSandboxConfigPath(); + ok(configPath, 'Config path should be defined'); + const config = JSON.parse(createdFiles.get(configPath)!); + ok(config.filesystem.allowWrite.includes('/workspace-plain'), 'Workspace write root without symlink should be preserved'); + ok(config.filesystem.allowWrite.includes('/write-plain'), 'Configured allowWrite without symlink should be preserved'); + ok(config.filesystem.allowRead.includes('/home/user/read-plain'), 'Configured allowRead without symlink should expand ~ and be preserved'); + ok(config.filesystem.denyRead.includes('/home/user/deny-read-plain'), 'Configured denyRead without symlink should expand ~ and be preserved'); + ok(config.filesystem.denyWrite.includes('/deny-write-plain'), 'Configured denyWrite without symlink should be preserved'); + }); + test('cleanupTempDir is a no-op when no temp dir was ever created', async () => { const host = createHost(); const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); @@ -144,6 +209,26 @@ suite('TerminalSandboxEngine', () => { strictEqual(await engine.isSandboxAllowNetworkEnabled(), false); }); + test('uses OS-specific filesystem absolute path detection', async () => { + const linuxEngine = store.add(instantiationService.createInstance(TerminalSandboxEngine, createHost())); + await linuxEngine.getOS(); + const isLinuxAbsolutePath = (linuxEngine as unknown as { _isAbsoluteFileSystemPath(path: string): boolean })._isAbsoluteFileSystemPath.bind(linuxEngine); + + strictEqual(isLinuxAbsolutePath('/home/user'), true); + strictEqual(isLinuxAbsolutePath('relative/path'), false); + strictEqual(isLinuxAbsolutePath('C:\\Users\\user'), false); + + const windowsEngine = store.add(instantiationService.createInstance(TerminalSandboxEngine, createHost({ getOS: () => Promise.resolve(OperatingSystem.Windows) }))); + await windowsEngine.getOS(); + const isWindowsAbsolutePath = (windowsEngine as unknown as { _isAbsoluteFileSystemPath(path: string): boolean })._isAbsoluteFileSystemPath.bind(windowsEngine); + + strictEqual(isWindowsAbsolutePath('/Users/user'), true); + strictEqual(isWindowsAbsolutePath('C:\\Users\\user'), true); + strictEqual(isWindowsAbsolutePath('C:/Users/user'), true); + strictEqual(isWindowsAbsolutePath('\\\\server\\share'), true); + strictEqual(isWindowsAbsolutePath('relative\\path'), false); + }); + test('checkForSandboxingPrereqs reports missing dependencies', async () => { let status: ISandboxDependencyStatus = { bubblewrapInstalled: false, socatInstalled: true }; const host = createHost({ From ed5047dcea4623e219f38b3d084f1d19d7a64f94 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Tue, 19 May 2026 10:38:19 +0200 Subject: [PATCH 05/10] Focus input on widget click outside textarea and action bar (#317271) fix: focus input on widget click outside textarea and action bar --- .../agentFeedbackEditorInputContribution.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts index 27fbcc74c6023..41eee06e22a7a 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts @@ -96,6 +96,20 @@ class AgentFeedbackInputWidget extends Disposable implements IOverlayWidget { this._updateActionForAlt(status.altKey); })); + // Focus the input when clicking anywhere on the widget that isn't the + // textarea itself or the action bar (e.g. padding around the textarea). + this._register(addStandardDisposableListener(this._domNode, 'mousedown', e => { + const target = e.target as Node | null; + if (target === this._inputElement) { + return; + } + if (actionsContainer.contains(target)) { + return; + } + e.preventDefault(); + this._inputElement.focus(); + })); + this._lineHeight = 22; this._inputElement.style.lineHeight = `${this._lineHeight}px`; } From 4df59e80ea39da9ed17e0399f299280690ee72c2 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 19 May 2026 19:10:34 +1000 Subject: [PATCH 06/10] fix: enhance request validation for CopilotCLI session type (#317266) * fix: enhance request validation for CopilotCLI session type * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/widget/input/chatInputPart.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index feadd701bc533..517da351a5c14 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -83,7 +83,7 @@ import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { ChatRequestVariableSet, getImageAttachmentLimit, IChatRequestVariableEntry, isBrowserViewVariableEntry, isElementVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isSCMHistoryItemChangeRangeVariableEntry, isSCMHistoryItemChangeVariableEntry, isSCMHistoryItemVariableEntry, isStringVariableEntry, OmittedState } from '../../../common/attachments/chatVariableEntries.js'; import { ChatMode, getModeNameForTelemetry, IChatMode, IChatModes, IChatModeService } from '../../../common/chatModes.js'; import { IChatFollowup, IChatPlanReview, IChatQuestionCarousel, IChatToolInvocation } from '../../../common/chatService/chatService.js'; -import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService, isIChatSessionFileChange2, localChatSessionType } from '../../../common/chatSessionsService.js'; +import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService, isIChatSessionFileChange2, localChatSessionType, SessionType } from '../../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../common/constants.js'; import { IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../common/languageModels.js'; @@ -1381,7 +1381,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (!sessionResource) { return; } - if (!requests || requests.length === 0) { + if (!requests || requests.length === 0 || getChatSessionType(sessionResource) !== SessionType.CopilotCLI) { return; } From 4704237c8787f22ba56c2ef91944c28bf4db9167 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Tue, 19 May 2026 11:26:55 +0200 Subject: [PATCH 07/10] provideChatSessionCustomizations for sessionResource (#316761) * provideChatSessionCustomizations for sessionResource * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * update * update * fix fixtures * update * update * AgentCustomizationItemProvider: remember _sessionCustomizations per session * update * update --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../copilotCLICustomizationProvider.ts | 2 +- .../copilotCLICustomizationProvider.spec.ts | 49 ++++----- .../claudeCustomizationProvider.ts | 2 +- .../test/claudeCustomizationProvider.spec.ts | 45 ++++---- ...emoteAgentHostCustomizationHarness.test.ts | 46 ++++---- .../browser/aiCustomizationShortcutsWidget.ts | 8 +- .../customizationsToolbar.contribution.ts | 11 +- .../aiCustomizationShortcutsWidget.fixture.ts | 8 +- .../api/browser/mainThreadChatAgents2.ts | 4 +- .../workbench/api/common/extHost.protocol.ts | 2 +- .../api/common/extHostChatAgents2.ts | 11 +- .../agentCustomizationItemProvider.ts | 28 ++--- .../aiCustomizationDebugPanel.ts | 36 +++---- .../aiCustomizationItemSource.ts | 3 +- .../aiCustomizationItemsModel.ts | 34 +++--- .../aiCustomizationListWidget.ts | 2 - .../aiCustomizationManagement.contribution.ts | 7 +- .../aiCustomizationManagementEditor.ts | 39 ++----- .../customizationHarnessService.ts | 17 ++- .../aiCustomization/pluginListWidget.ts | 3 +- ...promptsServiceCustomizationItemProvider.ts | 3 +- .../chatSessions/chatSessions.contribution.ts | 26 ++--- .../contrib/chat/browser/widget/chatWidget.ts | 4 +- .../input/editor/chatInputCompletions.ts | 2 +- .../input/editor/chatInputEditorContrib.ts | 2 +- .../contrib/chat/common/chatModes.ts | 2 +- .../common/customizationHarnessService.ts | 102 +++++++++++------- .../aiCustomizationItemsModel.test.ts | 55 ++++------ .../aiCustomizationListWidget.test.ts | 14 ++- .../customizationHarnessService.test.ts | 79 ++++++++------ .../aiCustomizationListWidget.fixture.ts | 8 +- ...aiCustomizationManagementEditor.fixture.ts | 90 +++++++++------- ...osed.chatSessionCustomizationProvider.d.ts | 3 +- 33 files changed, 402 insertions(+), 345 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts index 5063a8a673971..2cfacfbbab2be 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts @@ -55,7 +55,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod this._register(this.copilotCLIAgents.onDidChangeAgents(() => this._onDidChange.fire())); } - async provideChatSessionCustomizations(token: vscode.CancellationToken): Promise { + async provideChatSessionCustomizations(_sessionResource: vscode.Uri, token: vscode.CancellationToken): Promise { const [agents, instructions, skills, hooks, plugins] = await Promise.all([ this.getAgentItems(token), this.getInstructionItems(token), diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts index 500e4b1ad3a63..033620186371b 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts @@ -97,6 +97,7 @@ class TestCustomInstructionsService extends MockCustomInstructionsService { } describe('CopilotCLICustomizationProvider', () => { + const testSessionResource = URI.parse('copilotcli:///test-session'); let disposables: DisposableStore; let mockPromptsService: MockPromptsService; let mockCopilotCLIAgents: MockCopilotCLIAgents; @@ -146,7 +147,7 @@ describe('CopilotCLICustomizationProvider', () => { it('only returns items whose type is in supportedTypes', async () => { mockCopilotCLIAgents.setAgents([makeAgentInfo('explore', 'Explore')]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const supported = new Set(CopilotCLICustomizationProvider.metadata.supportedTypes!.map(t => t.id)); for (const item of items) { expect(supported.has(item.type.id), `item "${item.name}" has type "${item.type.id}" not in supportedTypes`).toBe(true); @@ -155,7 +156,7 @@ describe('CopilotCLICustomizationProvider', () => { it('does not set groupKey for items with synthetic URIs (vscode infers grouping)', async () => { mockCopilotCLIAgents.setAgents([makeAgentInfo('explore', 'Explore')]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const builtinItems = items.filter(i => i.uri.scheme !== 'file'); for (const item of builtinItems) { expect(item.groupKey, `item "${item.name}" should not have groupKey (vscode infers)`).toBeUndefined(); @@ -165,7 +166,7 @@ describe('CopilotCLICustomizationProvider', () => { describe('provideChatSessionCustomizations', () => { it('returns empty array when no files exist', async () => { - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); expect(items).toEqual([]); }); @@ -175,7 +176,7 @@ describe('CopilotCLICustomizationProvider', () => { makeAgentInfo('task', 'Multi-step tasks'), ]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const agentItems = items.filter((i: vscode.ChatSessionCustomizationItem) => i.type === FakeChatSessionCustomizationType.Agent); expect(agentItems).toHaveLength(2); expect(agentItems[0].name).toBe('explore'); @@ -186,7 +187,7 @@ describe('CopilotCLICustomizationProvider', () => { const fileUri = URI.file('/workspace/.github/explore.agent.md'); mockCopilotCLIAgents.setAgents([makeFileAgentInfo('explore', fileUri, 'Explore agent')]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const agentItems = items.filter((i: vscode.ChatSessionCustomizationItem) => i.type === FakeChatSessionCustomizationType.Agent); expect(agentItems).toHaveLength(1); expect(agentItems[0].uri).toEqual(fileUri); @@ -196,7 +197,7 @@ describe('CopilotCLICustomizationProvider', () => { it('uses synthetic URI for SDK-only agents', async () => { mockCopilotCLIAgents.setAgents([makeAgentInfo('task', 'Task agent')]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const agentItems = items.filter((i: vscode.ChatSessionCustomizationItem) => i.type === FakeChatSessionCustomizationType.Agent); expect(agentItems).toHaveLength(1); expect(agentItems[0].uri.scheme).toBe('copilotcli'); @@ -207,7 +208,7 @@ describe('CopilotCLICustomizationProvider', () => { it('uses displayName from agents when available', async () => { mockCopilotCLIAgents.setAgents([makeAgentInfo('code-review', 'Reviews code', 'Code Review')]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); expect(items[0].name).toBe('Code Review'); }); @@ -215,7 +216,7 @@ describe('CopilotCLICustomizationProvider', () => { const uri = URI.file('/workspace/.github/copilot-instructions.md'); mockPromptsService.setInstructions([makeInstruction(uri, 'copilot-instructions', undefined)]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); expect(items).toHaveLength(1); expect(items[0].uri).toBe(uri); expect(items[0].type).toBe(FakeChatSessionCustomizationType.Instructions); @@ -226,7 +227,7 @@ describe('CopilotCLICustomizationProvider', () => { const uri = URI.file('/workspace/.github/skills/lint-check/SKILL.md'); mockPromptsService.setSkills([makeSkill(uri, 'lint-check')]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); expect(items).toHaveLength(1); expect(items[0].uri).toBe(uri); expect(items[0].type).toBe(FakeChatSessionCustomizationType.Skill); @@ -237,7 +238,7 @@ describe('CopilotCLICustomizationProvider', () => { const uri = URI.file('/workspace/.copilot/skills/my-skill/SKILL.md'); mockPromptsService.setSkills([makeSkill(uri, 'my-skill')]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); expect(items).toHaveLength(1); expect(items[0].name).toBe('my-skill'); }); @@ -249,7 +250,7 @@ describe('CopilotCLICustomizationProvider', () => { mockPromptsService.setHooks([makeHook(URI.file('/workspace/.copilot/hooks/pre-commit.json'))]); mockPromptsService.setPlugins([makePlugin(URI.file('/workspace/.copilot/plugins/my-plugin'))]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); expect(items).toHaveLength(5); }); @@ -257,7 +258,7 @@ describe('CopilotCLICustomizationProvider', () => { const uri = URI.file('/workspace/.copilot/hooks/diagnostics.json'); mockPromptsService.setHooks([makeHook(uri)]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); expect(items).toHaveLength(1); expect(items[0].uri).toBe(uri); expect(items[0].type).toBe(FakeChatSessionCustomizationType.Hook); @@ -267,7 +268,7 @@ describe('CopilotCLICustomizationProvider', () => { it('strips .json extension from hook file name', async () => { mockPromptsService.setHooks([makeHook(URI.file('/workspace/.copilot/hooks/security-checks.json'))]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); expect(items[0].name).toBe('security-checks'); }); @@ -277,7 +278,7 @@ describe('CopilotCLICustomizationProvider', () => { makeHook(URI.file('/workspace/.copilot/hooks/diagnostics.json')), ]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const hookItems = items.filter((i: vscode.ChatSessionCustomizationItem) => i.type === FakeChatSessionCustomizationType.Hook); expect(hookItems).toHaveLength(2); }); @@ -286,7 +287,7 @@ describe('CopilotCLICustomizationProvider', () => { const uri = URI.file('/workspace/.copilot/plugins/lint-rules'); mockPromptsService.setPlugins([makePlugin(uri)]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); expect(items).toHaveLength(1); expect(items[0].uri).toEqual(uri); expect(items[0].type).toBe(FakeChatSessionCustomizationType.Plugins); @@ -300,7 +301,7 @@ describe('CopilotCLICustomizationProvider', () => { mockPromptsService.setInstructions([makeInstruction(uri, 'copilot-instructions', undefined)]); mockCustomInstructionsService.setAgentInstructionUris([uri]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); expect(instrItems).toHaveLength(1); expect(instrItems[0].groupKey).toBe('agent-instructions'); @@ -316,7 +317,7 @@ describe('CopilotCLICustomizationProvider', () => { mockPromptsService.setInstructions([]); mockCustomInstructionsService.setAgentInstructionUris([agentsUri, claudeUri, copilotUri]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); expect(instrItems).toHaveLength(3); expect(instrItems.every(i => i.groupKey === 'agent-instructions')).toBe(true); @@ -345,7 +346,7 @@ describe('CopilotCLICustomizationProvider', () => { mockPromptsService.setInstructions([]); mockCustomInstructionsService.setAgentInstructionUris([]); - const items = await testProvider.provideChatSessionCustomizations(undefined!); + const items = await testProvider.provideChatSessionCustomizations(testSessionResource, undefined!); const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); expect(instrItems).toHaveLength(2); expect(instrItems.every(i => i.groupKey === 'agent-instructions')).toBe(true); @@ -356,7 +357,7 @@ describe('CopilotCLICustomizationProvider', () => { const uri = URI.file('/workspace/.github/style.instructions.md'); mockPromptsService.setInstructions([makeInstruction(uri, 'style instructions', 'src/**/*.ts')]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); expect(instrItems).toHaveLength(1); expect(instrItems[0].groupKey).toBe('context-instructions'); @@ -368,7 +369,7 @@ describe('CopilotCLICustomizationProvider', () => { const uri = URI.file('/workspace/.github/global.instructions.md'); mockPromptsService.setInstructions([makeInstruction(uri, 'global instructions', '**')]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); expect(instrItems).toHaveLength(1); expect(instrItems[0].groupKey).toBe('context-instructions'); @@ -380,7 +381,7 @@ describe('CopilotCLICustomizationProvider', () => { const uri = URI.file('/workspace/.github/refactor.instructions.md'); mockPromptsService.setInstructions([makeInstruction(uri, 'refactor instructions', undefined, 'Refactoring guidelines')]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); expect(instrItems).toHaveLength(1); expect(instrItems[0].groupKey).toBe('on-demand-instructions'); @@ -392,7 +393,7 @@ describe('CopilotCLICustomizationProvider', () => { const uri = URI.file('/workspace/.github/testing.instructions.md'); mockPromptsService.setInstructions([makeInstruction(uri, 'testing instructions', '**/*.spec.ts', 'Testing standards')]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); expect(instrItems).toHaveLength(1); expect(instrItems[0].description).toBe('Testing standards'); @@ -408,7 +409,7 @@ describe('CopilotCLICustomizationProvider', () => { mockPromptsService.setFileContent(contextUri, '---\napplyTo: \'src/**\'\n---\nStyle rules.'); mockPromptsService.setFileContent(onDemandUri, '---\ndescription: Refactoring\n---\nRefactor tips.'); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); expect(instrItems).toHaveLength(3); @@ -431,7 +432,7 @@ describe('CopilotCLICustomizationProvider', () => { mockPromptsService.setInstructions([makeInstruction(uri, 'plain instructions', undefined)]); mockPromptsService.setFileContent(uri, 'Just plain text, no frontmatter.'); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); expect(instrItems).toHaveLength(1); expect(instrItems[0].groupKey).toBe('on-demand-instructions'); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts index 2bb765d1f63b6..b26f9962a5cf7 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts @@ -93,7 +93,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch this._register(this.workspaceService.onDidChangeWorkspaceFolders(() => this._onDidChange.fire())); } - async provideChatSessionCustomizations(token: vscode.CancellationToken): Promise { + async provideChatSessionCustomizations(_sessionResource: vscode.Uri, token: vscode.CancellationToken): Promise { const items: vscode.ChatSessionCustomizationItem[] = []; // Agents: hybrid approach — file-based .claude/ agents merged with SDK-provided agents. diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts index 3c226eeffd1da..7110f5b971afe 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts @@ -85,6 +85,7 @@ class TestLogService extends mock() { } describe('ClaudeCustomizationProvider', () => { + const testSessionResource = URI.parse('claude:///test-session'); let disposables: DisposableStore; let mockRuntimeDataService: MockRuntimeDataService; let mockPromptsService: MockPromptsService; @@ -137,7 +138,7 @@ describe('ClaudeCustomizationProvider', () => { mockRuntimeDataService.setAgents([ { name: 'Explore', description: 'Fast exploration agent' }, ]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const supported = new Set(ClaudeCustomizationProvider.metadata.supportedTypes!.map(t => t.id)); for (const item of items) { expect(supported.has(item.type.id), `item "${item.name}" has type "${item.type.id}" which is not in supportedTypes`).toBe(true); @@ -148,7 +149,7 @@ describe('ClaudeCustomizationProvider', () => { mockRuntimeDataService.setAgents([ { name: 'Explore', description: 'Explore agent' }, ]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const builtinItems = items.filter(i => i.uri.scheme !== 'file'); for (const item of builtinItems) { expect(item.groupKey, `item "${item.name}" with scheme "${item.uri.scheme}" should not have groupKey (vscode infers)`).toBeUndefined(); @@ -158,7 +159,7 @@ describe('ClaudeCustomizationProvider', () => { describe('agents from SDK', () => { it('returns empty when no session has initialized and no file agents', async () => { - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); expect(items).toEqual([]); }); @@ -168,7 +169,7 @@ describe('ClaudeCustomizationProvider', () => { { name: 'Review', description: 'Code review agent', model: 'claude-3.5-sonnet' }, ]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const agentItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Agent); expect(agentItems).toHaveLength(2); expect(agentItems[0].name).toBe('Explore'); @@ -185,7 +186,7 @@ describe('ClaudeCustomizationProvider', () => { mockAgent(URI.file('/workspace/.claude/agents/my-agent.agent.md'), 'my-agent'), ]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const agentItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Agent); expect(agentItems).toHaveLength(1); expect(agentItems[0].name).toBe('my-agent'); @@ -201,7 +202,7 @@ describe('ClaudeCustomizationProvider', () => { mockAgent(URI.file('/workspace/.claude/agents/my-agent.agent.md'), 'my-agent'), ]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const agentItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Agent); expect(agentItems).toHaveLength(1); expect(agentItems[0].description).toBe('SDK version'); @@ -215,7 +216,7 @@ describe('ClaudeCustomizationProvider', () => { mockAgent(URI.file('/workspace/root.agent.md'), 'root-agent'), ]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const agentItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Agent); expect(agentItems).toHaveLength(0); }); @@ -230,7 +231,7 @@ describe('ClaudeCustomizationProvider', () => { const uri = URI.joinPath(URI.file('/workspace'), 'CLAUDE.md'); mockFileSystemService.setFile(uri, '# Instructions'); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); expect(instructionItems).toHaveLength(1); expect(instructionItems[0].name).toBe('CLAUDE'); @@ -241,7 +242,7 @@ describe('ClaudeCustomizationProvider', () => { const uri = URI.joinPath(URI.file('/workspace'), 'CLAUDE.local.md'); mockFileSystemService.setFile(uri, '# Local'); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); expect(instructionItems).toHaveLength(1); expect(instructionItems[0].name).toBe('CLAUDE.local'); @@ -251,7 +252,7 @@ describe('ClaudeCustomizationProvider', () => { const uri = URI.joinPath(URI.file('/workspace'), '.claude', 'CLAUDE.md'); mockFileSystemService.setFile(uri, '# Claude dir'); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); expect(instructionItems).toHaveLength(1); expect(instructionItems[0].name).toBe('CLAUDE'); @@ -261,7 +262,7 @@ describe('ClaudeCustomizationProvider', () => { const uri = URI.joinPath(URI.file('/home/user'), '.claude', 'CLAUDE.md'); mockFileSystemService.setFile(uri, '# Home'); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); expect(instructionItems).toHaveLength(1); expect(instructionItems[0].uri).toEqual(uri); @@ -272,7 +273,7 @@ describe('ClaudeCustomizationProvider', () => { const uri = URI.joinPath(URI.file('/workspace'), 'CLAUDE.md'); mockFileSystemService.setFile(uri, '# Only this one'); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); expect(instructionItems).toHaveLength(1); }); @@ -287,7 +288,7 @@ describe('ClaudeCustomizationProvider', () => { const uri = URI.file('/workspace/.claude/skills/my-skill/SKILL.md'); mockPromptsService.setSkills([mockSkill(uri, 'my-skill')]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill); expect(skillItems).toHaveLength(1); expect(skillItems[0].uri).toBe(uri); @@ -300,7 +301,7 @@ describe('ClaudeCustomizationProvider', () => { mockSkill(URI.file('/workspace/.copilot/skills/other/SKILL.md'), 'other-skill'), ]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill); expect(skillItems).toHaveLength(0); }); @@ -309,7 +310,7 @@ describe('ClaudeCustomizationProvider', () => { const uri = URI.file('/home/user/.claude/skills/global-skill/SKILL.md'); mockPromptsService.setSkills([mockSkill(uri, 'global-skill')]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill); expect(skillItems).toHaveLength(1); }); @@ -326,7 +327,7 @@ describe('ClaudeCustomizationProvider', () => { JSON.stringify({ hooks: { SessionStart: [{ matcher: '*', hooks: [{ type: 'command', command: './init.sh' }] }] } }) ); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); expect(items.filter(i => i.type === FakeChatSessionCustomizationType.Agent)).toHaveLength(1); expect(items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions)).toHaveLength(1); expect(items.filter(i => i.type === FakeChatSessionCustomizationType.Skill)).toHaveLength(1); @@ -347,7 +348,7 @@ describe('ClaudeCustomizationProvider', () => { } })); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook); expect(hookItems).toHaveLength(1); expect(hookItems[0].name).toBe('PreToolUse (Bash)'); @@ -369,7 +370,7 @@ describe('ClaudeCustomizationProvider', () => { }) ); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook); expect(hookItems).toHaveLength(1); expect(hookItems[0].name).toBe('SessionStart'); @@ -385,7 +386,7 @@ describe('ClaudeCustomizationProvider', () => { } })); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook); expect(hookItems).toHaveLength(1); expect(hookItems[0].name).toBe('PostToolUse (Edit)'); @@ -409,7 +410,7 @@ describe('ClaudeCustomizationProvider', () => { }) ); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook); expect(hookItems).toHaveLength(4); }); @@ -417,7 +418,7 @@ describe('ClaudeCustomizationProvider', () => { it('gracefully handles missing settings files', async () => { mockWorkspaceService.setFolders([URI.file('/workspace')]); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); expect(items).toEqual([]); }); @@ -429,7 +430,7 @@ describe('ClaudeCustomizationProvider', () => { 'not valid json {' ); - const items = await provider.provideChatSessionCustomizations(undefined!); + const items = await provider.provideChatSessionCustomizations(testSessionResource, undefined!); expect(items).toEqual([]); }); }); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts index 0f626f66c8a84..04f6b6c32549d 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts @@ -76,10 +76,13 @@ function createNotificationService(): INotificationService { } }; } +const testSessionResource = URI.parse('agent-host-copilotcli:/session-1'); +const agentHostProviderId = 'copilotcli'; +const agentHostSessionId = `${agentHostProviderId}:/session-1`; function createAgentInfo(customizations: readonly CustomizationRef[]): AgentInfo { return { - provider: 'copilotcli', + provider: agentHostProviderId, displayName: 'Copilot', description: 'Test Agent', models: [], @@ -87,9 +90,13 @@ function createAgentInfo(customizations: readonly CustomizationRef[]): AgentInfo }; } + + suite('RemoteAgentHostCustomizationHarness', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('removeConfiguredPlugin keeps sibling scopes for the same URI', async () => { const connection = disposables.add(new MockAgentConnection()); const controller = disposables.add(new RemoteAgentPluginController( @@ -154,7 +161,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { new NullLogService(), )); - const items = await provider.provideChatSessionCustomizations(CancellationToken.None); + const items = await provider.provideChatSessionCustomizations(testSessionResource, CancellationToken.None); assert.strictEqual(items.length, 2); assert.notStrictEqual(items[0].itemKey, items[1].itemKey); }); @@ -199,12 +206,12 @@ suite('RemoteAgentHostCustomizationHarness', () => { origin: undefined, action: { type: ActionType.SessionCustomizationsChanged, - session: 'agent://copilotcli/session-1', + session: agentHostSessionId, customizations: [synced], }, }); - const items = await provider.provideChatSessionCustomizations(CancellationToken.None); + const items = await provider.provideChatSessionCustomizations(testSessionResource, CancellationToken.None); assert.strictEqual(items.length, 2); assert.notStrictEqual(items[0].itemKey, items[1].itemKey); }); @@ -250,12 +257,12 @@ suite('RemoteAgentHostCustomizationHarness', () => { origin: undefined, action: { type: ActionType.SessionCustomizationsChanged, - session: 'agent://copilotcli/session-1', + session: agentHostSessionId, customizations: [synced], }, }); - const items = await provider.provideChatSessionCustomizations(CancellationToken.None); + const items = await provider.provideChatSessionCustomizations(testSessionResource, CancellationToken.None); assert.strictEqual(items.length, 2); const hostItem = items.find(i => i.name === 'Host Plugin'); @@ -348,12 +355,12 @@ suite('RemoteAgentHostCustomizationHarness', () => { origin: undefined, action: { type: ActionType.SessionCustomizationsChanged, - session: 'agent://copilotcli/session-1', + session: agentHostSessionId, customizations: [synced], }, }); - const items = await provider.provideChatSessionCustomizations(CancellationToken.None); + const items = await provider.provideChatSessionCustomizations(testSessionResource, CancellationToken.None); // The synthetic bundle itself should NOT appear as a top-level item assert.ok(!items.some(i => i.name === 'VS Code Synced Data'), 'synthetic bundle should be hidden'); // But its expanded child should appear @@ -402,12 +409,12 @@ suite('RemoteAgentHostCustomizationHarness', () => { origin: undefined, action: { type: ActionType.SessionCustomizationsChanged, - session: 'agent://copilotcli/session-1', + session: agentHostSessionId, customizations: [synced], }, }); - const items = await provider.provideChatSessionCustomizations(CancellationToken.None); + const items = await provider.provideChatSessionCustomizations(testSessionResource, CancellationToken.None); // No top-level item (bundle is hidden), but check that plugin expansion // attempted with the original scheme — not agent-host:// // This is verified indirectly: canHandleResource returns false so @@ -456,12 +463,12 @@ suite('RemoteAgentHostCustomizationHarness', () => { origin: undefined, action: { type: ActionType.SessionCustomizationsChanged, - session: 'agent://copilotcli/session-1', + session: agentHostSessionId, customizations: [sessionCustomization], }, }); - const items = await provider.provideChatSessionCustomizations(CancellationToken.None); + const items = await provider.provideChatSessionCustomizations(testSessionResource, CancellationToken.None); // Host-scoped plugin from root + session customization → merged into one entry // The session customization entry updates status/statusMessage const sessionItem = items.find(i => i.status === 'error'); @@ -505,7 +512,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { origin: undefined, action: { type: ActionType.SessionCustomizationsChanged, - session: 'agent://copilotcli/session-1', + session: agentHostSessionId, customizations: [{ customization: pluginRef, enabled: true, @@ -551,7 +558,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { origin: undefined, action: { type: ActionType.SessionCustomizationsChanged, - session: 'agent://copilotcli/session-1', + session: agentHostSessionId, customizations: [{ customization: clientPlugin, clientId: 'test-client', @@ -560,7 +567,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { }, }); - const items = await provider.provideChatSessionCustomizations(CancellationToken.None); + const items = await provider.provideChatSessionCustomizations(testSessionResource, CancellationToken.None); const hostItem = items.find(i => i.name === 'Host Plugin'); const clientItem = items.find(i => i.name === 'Client Plugin'); @@ -639,7 +646,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { origin: undefined, action: { type: ActionType.SessionCustomizationsChanged, - session: 'agent://copilotcli/session-1', + session: agentHostSessionId, customizations: [ { customization: clientA, clientId: 'test-client', enabled: true }, { customization: clientB, clientId: 'test-client', enabled: true }, @@ -647,7 +654,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { }, }); - const items = await provider.provideChatSessionCustomizations(CancellationToken.None); + const items = await provider.provideChatSessionCustomizations(testSessionResource, CancellationToken.None); assert.strictEqual(items.length, 2); assert.ok(items.find(i => i.name === 'Client A'), 'should have Client A'); assert.ok(items.find(i => i.name === 'Client B'), 'should have Client B'); @@ -710,7 +717,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { new NullLogService(), )); - const items = await provider.provideChatSessionCustomizations(CancellationToken.None); + const items = await provider.provideChatSessionCustomizations(testSessionResource, CancellationToken.None); const skillItems = items.filter(i => i.type === PromptsType.skill); assert.deepStrictEqual( @@ -780,6 +787,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { )); const harnessId = 'remote-agent-host-test'; + const testSessionResource = URI.parse('remote-agent-host-test:///test-session'); const descriptor: IHarnessDescriptor = { id: harnessId, label: 'Remote Agent Host (test)', @@ -789,7 +797,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { }; const harnessService = disposables.add(new CustomizationHarnessServiceBase([descriptor], harnessId, new MockPromptsService())); - const commands = await harnessService.getSlashCommands(harnessId, CancellationToken.None); + const commands = await harnessService.getSlashCommands(testSessionResource, CancellationToken.None); const skillCommand = commands.find(c => c.type === PromptsType.skill); assert.ok(skillCommand, 'should have a skill slash command'); assert.strictEqual(skillCommand.name, 'skills-bundle:lint', 'skill command name should be plugin-prefixed'); diff --git a/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts b/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts index 248f9857a9ae0..791b8266fd8a8 100644 --- a/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts @@ -19,7 +19,7 @@ import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultS import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; import { IAICustomizationItemsModel } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.js'; import { ICustomizationHarnessService } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; -import { CUSTOMIZATION_ITEMS, findHarnessIdForSession, SESSIONS_CUSTOMIZATIONS_SIDEBAR_MODE_SETTING, SessionsCustomizationsSidebarMode } from './customizationsToolbar.contribution.js'; +import { CUSTOMIZATION_ITEMS, SESSIONS_CUSTOMIZATIONS_SIDEBAR_MODE_SETTING, SessionsCustomizationsSidebarMode } from './customizationsToolbar.contribution.js'; import { Menus } from '../../../browser/menus.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; @@ -167,9 +167,9 @@ export class AICustomizationShortcutsWidget extends Disposable { } private async _openWelcomePage(): Promise { - const harnessId = findHarnessIdForSession(this.sessionsManagementService.activeSession.get(), this.harnessService); - if (harnessId) { - this.harnessService.setActiveHarness(harnessId); + const sessionResource = this.sessionsManagementService.activeSession.get()?.resource; + if (sessionResource) { + this.harnessService.setActiveSession(sessionResource); } const input = AICustomizationManagementEditorInput.getOrCreate(); diff --git a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts index e2922f47ceaf0..4c67a76bd3248 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts @@ -288,9 +288,9 @@ export class CustomizationsToolbarContribution extends Disposable implements IWo const harnessService = accessor.get(ICustomizationHarnessService); const sessionsManagementService = accessor.get(ISessionsManagementService); const configurationService = accessor.get(IConfigurationService); - const harnessId = findHarnessIdForSession(sessionsManagementService.activeSession.get(), harnessService); - if (harnessId) { - harnessService.setActiveHarness(harnessId); + const sessionResource = sessionsManagementService.activeSession.get()?.resource; + if (sessionResource) { + harnessService.setActiveSession(sessionResource); } const input = AICustomizationManagementEditorInput.getOrCreate(); const pane = await editorService.openEditor(input, { pinned: true }); @@ -367,10 +367,7 @@ export class ActiveSessionHarnessSyncContribution extends Disposable implements // (e.g. agent host, CLI) registers asynchronously after the session // has already been selected. harnessService.availableHarnesses.read(reader); - const harnessId = findHarnessIdForSession(session, harnessService); - if (harnessId) { - harnessService.setActiveHarness(harnessId); - } + harnessService.setActiveSession(session.resource); })); } } diff --git a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts index 676c27b444a2e..6c895badd237a 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts @@ -6,7 +6,7 @@ import { toAction } from '../../../../../base/common/actions.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { derived, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { IActionViewItemFactory, IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; @@ -18,6 +18,7 @@ import { IMcpServer, IMcpService } from '../../../../../workbench/contrib/mcp/co import { IAgentPluginService } from '../../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; import { IAICustomizationItemsModel, ItemsModelSection } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.js'; import { ICustomizationHarnessService, IHarnessDescriptor } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; +import { getChatSessionType } from '../../../../../workbench/contrib/chat/common/model/chatUri.js'; import { AICustomizationManagementSection } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IAICustomizationListItem } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.js'; import { AICustomizationShortcutsWidget } from '../../browser/aiCustomizationShortcutsWidget.js'; @@ -26,11 +27,13 @@ import { IEditorService } from '../../../../../workbench/services/editor/common/ import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; import { Menus } from '../../../../browser/menus.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { URI } from '../../../../../base/common/uri.js'; // Ensure color registrations are loaded import '../../../../common/theme.js'; import '../../../../../platform/theme/common/colors/inputColors.js'; + // ============================================================================ // One-time menu item registration (module-level). // MenuRegistry.appendMenuItem does not throw on duplicates, unlike registerAction2 @@ -156,7 +159,8 @@ function createMockHarnessService(hiddenSections: readonly string[] = []): ICust getStorageSourceFilter: () => ({ sources: [] }), }; return new class extends mock() { - override readonly activeHarness = observableValue('mockActiveHarness', descriptor.id); + override readonly activeSessionResource = observableValue('mockActiveSessionResource', URI.parse(`${descriptor.id}:///session`)); + override readonly activeHarness = derived(reader => getChatSessionType(this.activeSessionResource.read(reader))); override readonly availableHarnesses = observableValue('mockAvailableHarnesses', [descriptor]); override findHarnessById(id: string) { return id === descriptor.id ? descriptor : undefined; } override getActiveDescriptor() { return descriptor; } diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index a1efe056b9363..2eedd905975ef 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -758,8 +758,8 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA // Build the item provider that calls back to the ExtHost const itemProvider: ICustomizationItemProvider = { onDidChange: emitter.event, - provideChatSessionCustomizations: async (token) => { - const items = await this._proxy.$provideChatSessionCustomizations(handle, token); + provideChatSessionCustomizations: async (sessionResource, token) => { + const items = await this._proxy.$provideChatSessionCustomizations(handle, sessionResource, token); if (!items) { return undefined; } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 4a874a6e9ad93..83c858754ee3e 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1712,7 +1712,7 @@ export interface ExtHostChatAgentsShape2 { $releaseSession(sessionResource: UriComponents): void; $detectChatParticipant(handle: number, request: Dto, context: { history: IChatAgentHistoryEntryDto[] }, options: { participants: IChatParticipantMetadata[]; location: ChatAgentLocation }, token: CancellationToken): Promise; $providePromptFiles(handle: number, type: PromptsType, context: IPromptFileContext, token: CancellationToken): Promise[] | undefined>; - $provideChatSessionCustomizations(handle: number, token: CancellationToken): Promise; + $provideChatSessionCustomizations(handle: number, sessionResource: UriComponents, token: CancellationToken): Promise; $setRequestTools(requestId: string, tools: UserSelectedTools): void; $setYieldRequested(requestId: string, value: boolean): void; $acceptActiveChatSession(sessionResource: UriComponents | undefined): void; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index ca9cfd1df45e7..98035103bdc58 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -821,14 +821,21 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return disposables; } - async $provideChatSessionCustomizations(handle: number, token: CancellationToken): Promise { + async $provideChatSessionCustomizations(handle: number, sessionResource: UriComponents | undefined, token: CancellationToken): Promise { const providerData = this._customizationProviders.get(handle); if (!providerData) { return undefined; } + // The proposed API requires a real session URI; bail out when the + // internal caller (e.g. the management UI populating a global list) + // has nothing scoped to forward. + if (!sessionResource) { + return undefined; + } + try { - const items = await providerData.provider.provideChatSessionCustomizations(token); + const items = await providerData.provider.provideChatSessionCustomizations(URI.revive(sessionResource), token); if (!items) { return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts index 27a88354d62eb..e361da26a31a9 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts @@ -16,7 +16,7 @@ import { ICustomizationItem, ICustomizationItemAction, ICustomizationItemProvide import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../services/agentHost/common/agentHostFileSystemService.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; -import { type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AgentSession, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { ActionType } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { getAgentHostConfiguredCustomizations } from '../../../../../../platform/agentHost/common/agentHostCustomizationConfig.js'; import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; @@ -34,21 +34,21 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto readonly onDidChange: Event = this._onDidChange.event; private _agentCustomizations: readonly CustomizationRef[]; - private _sessionCustomizations: readonly SessionCustomization[] | undefined; + private readonly _sessionCustomizationsCache = new Map(); /** Cache: pluginUri → last expansion (keyed by nonce so we re-fetch on content change). */ private readonly _expansionCache = new ResourceMap<{ nonce: string | undefined; children: readonly ICustomizationItem[] }>(); constructor( private readonly _agentInfo: AgentInfo, - connection: IAgentConnection, + private readonly _connection: IAgentConnection, private readonly _connectionAuthority: string, private readonly _fileService: IFileService, private readonly _logService: ILogService, private readonly _getItemActions?: (customization: CustomizationRef, clientId: string | undefined) => ICustomizationItemAction[] | undefined, ) { super(); - const rootStateSubscription = connection.rootState; + const rootStateSubscription = this._connection.rootState; this._agentCustomizations = this._readRootCustomizations(rootStateSubscription.value) ?? this._agentInfo.customizations ?? []; this._register(rootStateSubscription.onDidChange(rootState => { @@ -59,13 +59,10 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto } })); - this._register(connection.onDidAction(envelope => { + this._register(this._connection.onDidAction(envelope => { if (envelope.action.type === ActionType.SessionCustomizationsChanged) { - const customizations = envelope.action.customizations; - if (customizations !== this._sessionCustomizations) { - this._sessionCustomizations = customizations; - this._onDidChange.fire(); - } + this._sessionCustomizationsCache.set(envelope.action.session, envelope.action.customizations); + this._onDidChange.fire(); } })); } @@ -134,8 +131,12 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto actions: this._getItemActions?.(customization, clientId), }; } + private _resolveSessionUri(sessionResource: URI): URI { + const rawId = sessionResource.path.substring(1); + return AgentSession.uri(this._agentInfo.provider, rawId); + } - async provideChatSessionCustomizations(token: CancellationToken): Promise { + async provideChatSessionCustomizations(sessionResource: URI, token: CancellationToken): Promise { const items = new Map(); // Build parent plugin items keyed by customization ref @@ -147,8 +148,9 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto items.set(customizationItemKey(customization, undefined), item); plugins.push({ item, nonce: customization.nonce, status: undefined, statusMessage: undefined, enabled: undefined, childGroupKey: REMOTE_HOST_GROUP, isBundleItem: false }); } - - for (const sessionCustomization of this._sessionCustomizations ?? []) { + const sessionUri = this._resolveSessionUri(sessionResource); + const sessionCustomizations = this._sessionCustomizationsCache.get(sessionUri.toString()) ?? []; + for (const sessionCustomization of sessionCustomizations) { const isBundleItem = isSyntheticBundle(sessionCustomization.customization); const isClientSynced = sessionCustomization.clientId !== undefined; const childGroupKey = isClientSynced ? REMOTE_CLIENT_GROUP : REMOTE_HOST_GROUP; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts index 08f937f320bcd..7c0881f3c372b 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts @@ -9,7 +9,7 @@ import { IPromptsService, PromptsStorage, IPromptPath } from '../../common/promp import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IAICustomizationWorkspaceService, applyStorageSourceFilter, IStorageSourceFilter } from '../../common/aiCustomizationWorkspaceService.js'; import { type AICustomizationPromptsStorage, AICustomizationManagementSection, BUILTIN_STORAGE, sectionToPromptType } from './aiCustomizationManagement.js'; -import { ICustomizationHarnessService, ICustomizationItemProvider, IHarnessDescriptor } from '../../common/customizationHarnessService.js'; +import { ICustomizationHarnessService, ICustomizationItemProvider } from '../../common/customizationHarnessService.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; /** @@ -34,10 +34,9 @@ export async function generateCustomizationDebugReport( promptsService: IPromptsService, workspaceService: IAICustomizationWorkspaceService, widgetState: IDebugWidgetState, - activeDescriptor?: IHarnessDescriptor, - promptsServiceItemProvider?: ICustomizationItemProvider, - harnessService?: ICustomizationHarnessService, - agentPluginService?: IAgentPluginService, + promptsServiceItemProvider: ICustomizationItemProvider, + harnessService: ICustomizationHarnessService, + agentPluginService: IAgentPluginService, ): Promise { const promptType = sectionToPromptType(section); const filter = workspaceService.getStorageSourceFilter(promptType); @@ -49,6 +48,8 @@ export async function generateCustomizationDebugReport( lines.push(`Sections: [${workspaceService.managementSections.join(', ')}]`); lines.push(`Filter sources: [${filter.sources.join(', ')}]`); + const activeDescriptor = harnessService.getActiveDescriptor(); + // Active harness descriptor if (activeDescriptor) { lines.push(''); @@ -75,29 +76,22 @@ export async function generateCustomizationDebugReport( lines.push(''); // Determine which provider the widget actually uses (mirrors getItemSource logic) - const extensionProvider = activeDescriptor?.itemProvider; - const effectiveProvider = extensionProvider ?? promptsServiceItemProvider; + const extensionProvider = activeDescriptor.itemProvider; // Stage 1: Provider output - if (effectiveProvider) { - let providerLabel: string; - if (extensionProvider) { - providerLabel = 'Extension Provider'; - } else { - providerLabel = 'PromptsService Adapter (fallback — no extension provider registered)'; - } - await appendProviderData(lines, effectiveProvider, promptType, providerLabel); + if (extensionProvider) { + const providerLabel = 'Extension Provider'; + const sessionResource = harnessService.activeSessionResource.get(); + await appendProviderData(lines, extensionProvider, sessionResource, promptType, providerLabel); } else { + // Stage 2: Raw PromptsService data — always useful for diagnostics lines.push('--- Stage 1: No provider available ---'); lines.push(''); - } - - // Stage 2: Raw PromptsService data — always useful for diagnostics - if (!extensionProvider) { await appendRawServiceData(lines, promptsService, promptType); await appendFilteredData(lines, promptsService, promptType, filter); } + // Stage 3: Widget state appendWidgetState(lines, widgetState); @@ -133,10 +127,10 @@ async function getPromptFilesByStorage(promptsService: IPromptsService, promptTy return { localFiles, userFiles, extensionFiles }; } -async function appendProviderData(lines: string[], provider: ICustomizationItemProvider, promptType: PromptsType, label: string): Promise { +async function appendProviderData(lines: string[], provider: ICustomizationItemProvider, sessionResource: URI, promptType: PromptsType, label: string): Promise { lines.push(`--- Stage 1: Provider Output (${label}) ---`); - const allItems = await provider.provideChatSessionCustomizations(CancellationToken.None); + const allItems = await provider.provideChatSessionCustomizations(sessionResource, CancellationToken.None); if (!allItems) { lines.push(' Provider returned undefined'); lines.push(''); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index 193ef90d6ee2f..5f1a454075504 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -301,6 +301,7 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour readonly onDidChange: Event; constructor( + private readonly sessionResource: URI, private readonly itemProvider: ICustomizationItemProvider | undefined, private readonly syncProvider: ICustomizationSyncProvider | undefined, private readonly promptsService: IPromptsService, @@ -349,7 +350,7 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour } private async fetchItemsFromProvider(provider: ICustomizationItemProvider, promptType: PromptsType): Promise { - const allItems = await provider.provideChatSessionCustomizations(CancellationToken.None); + const allItems = await provider.provideChatSessionCustomizations(this.sessionResource, CancellationToken.None); if (!allItems) { return []; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts index 9963a41b8312b..66972aadc3c42 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts @@ -7,7 +7,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { onUnexpectedError } from '../../../../../base/common/errors.js'; import { Disposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { autorun, derived, IObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; -import { basename } from '../../../../../base/common/resources.js'; +import { basename, isEqual } from '../../../../../base/common/resources.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; @@ -22,6 +22,8 @@ import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; import { AICustomizationItemNormalizer, IAICustomizationItemSource, IAICustomizationListItem, ProviderCustomizationItemSource } from './aiCustomizationItemSource.js'; import { PromptsServiceCustomizationItemProvider } from './promptsServiceCustomizationItemProvider.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ResourceMap } from '../../../../../base/common/map.js'; /** * The set of sections whose items are sourced from the customization @@ -108,7 +110,7 @@ export class AICustomizationItemsModel extends Disposable implements IAICustomiz * fresh source bound to the new provider. Pruned when its descriptor is no longer * present in `availableHarnesses`. */ - private readonly sourceCache = new Map(); + private readonly sourceCache = new ResourceMap(); private readonly perSection = new Map>(); private readonly perSectionCount = new Map>(); @@ -176,11 +178,10 @@ export class AICustomizationItemsModel extends Disposable implements IAICustomiz // active id), prune the source cache, and refetch any observed sections. const sourceChangeListener = this._register(new MutableDisposable()); this._register(autorun(reader => { - const available = this.harnessService.availableHarnesses.read(reader); - this.harnessService.activeHarness.read(reader); - this.pruneSourceCache(available); + const activeSessionResource = this.harnessService.activeSessionResource.read(reader); + this.pruneSourceCache(activeSessionResource); const descriptor = this.harnessService.getActiveDescriptor(); - const source = this.getOrCreateSource(descriptor); + const source = this.getOrCreateSource(descriptor, activeSessionResource); sourceChangeListener.value = source.onDidChange(() => this.refetchObserved(source)); this.refetchObserved(source); })); @@ -210,7 +211,7 @@ export class AICustomizationItemsModel extends Disposable implements IAICustomiz } getActiveItemSource(): IAICustomizationItemSource { - return this.getOrCreateSource(this.harnessService.getActiveDescriptor()); + return this.getOrCreateSource(this.harnessService.getActiveDescriptor(), this.harnessService.activeSessionResource.get()); } getPromptsServiceItemProvider(): ICustomizationItemProvider { @@ -238,13 +239,14 @@ export class AICustomizationItemsModel extends Disposable implements IAICustomiz this.refetchPluginCount(this.getActiveItemSource()); } - private getOrCreateSource(descriptor: IHarnessDescriptor): IAICustomizationItemSource { - const cached = this.sourceCache.get(descriptor); + private getOrCreateSource(descriptor: IHarnessDescriptor, sessionResource: URI): IAICustomizationItemSource { + const cached = this.sourceCache.get(sessionResource); if (cached) { return cached; } const itemProvider = descriptor.itemProvider ?? (descriptor.syncProvider ? undefined : this.promptsServiceItemProvider); const source = new ProviderCustomizationItemSource( + sessionResource, itemProvider, descriptor.syncProvider, this.promptsService, @@ -253,15 +255,14 @@ export class AICustomizationItemsModel extends Disposable implements IAICustomiz this.pathService, this.itemNormalizer, ); - this.sourceCache.set(descriptor, source); + this.sourceCache.set(sessionResource, source); return source; } - private pruneSourceCache(available: readonly IHarnessDescriptor[]): void { - const live = new Set(available); - for (const descriptor of this.sourceCache.keys()) { - if (!live.has(descriptor)) { - this.sourceCache.delete(descriptor); + private pruneSourceCache(activeSessionResource: URI): void { + for (const sessionResource of this.sourceCache.keys()) { + if (!isEqual(sessionResource, activeSessionResource)) { + this.sourceCache.delete(sessionResource); } } } @@ -304,10 +305,11 @@ export class AICustomizationItemsModel extends Disposable implements IAICustomiz private refetchPluginCount(source: IAICustomizationItemSource): void { const seq = ++this.pluginFetchSeq; + const sessionRessource = this.harnessService.activeSessionResource.get(); const descriptor = this.harnessService.getActiveDescriptor(); const provider = descriptor.itemProvider; const pending: Promise = provider - ? provider.provideChatSessionCustomizations(CancellationToken.None).then(items => { + ? provider.provideChatSessionCustomizations(sessionRessource, CancellationToken.None).then(items => { return (items ?? []) .filter(item => isPluginCustomizationItem(item) && item.groupKey !== 'remote-client') .map(item => item.name ?? ''); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 974a488765a7f..fa698e9dec90d 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -1596,13 +1596,11 @@ export class AICustomizationListWidget extends Disposable { if (this._store.isDisposed) { return ''; } - const activeDescriptor = this.harnessService.getActiveDescriptor(); return generateCustomizationDebugReport( this.currentSection, this.promptsService, this.workspaceService, { allItems: this.allItems as IAICustomizationListItem[], displayEntries: this.displayEntries }, - activeDescriptor, this.itemsModel.getPromptsServiceItemProvider(), this.harnessService, this.agentPluginService, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index 15115d687d50a..65e918a062890 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -36,7 +36,6 @@ import { IWorkbenchExtensionManagementService } from '../../../../services/exten import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; -import { getChatSessionType } from '../../common/model/chatUri.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; @@ -768,11 +767,7 @@ class AICustomizationManagementActionsContribution extends Disposable implements // so the customization editor opens in the matching context. const sessionResource = chatWidgetService.lastFocusedWidget?.viewModel?.sessionResource; if (sessionResource) { - const sessionType = getChatSessionType(sessionResource); - const harness = harnessService.findHarnessById(sessionType); - if (harness) { - harnessService.setActiveHarness(sessionType); - } + harnessService.setActiveSession(sessionResource); } const input = AICustomizationManagementEditorInput.getOrCreate(); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 564c253093bbc..a04198d2649c7 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -92,7 +92,6 @@ import { ICustomizationHarnessService, matchesWorkspaceSubpath } from '../../com import { ChatConfiguration } from '../../common/constants.js'; import { AICustomizationWelcomePage } from './aiCustomizationWelcomePage.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; -import { SessionType } from '../../common/chatSessionsService.js'; const $ = DOM.$; @@ -507,7 +506,7 @@ export class AICustomizationManagementEditor extends EditorPane { * When disabled, the editor behaves as if "Local" is always selected. */ private get isHarnessSelectorEnabled(): boolean { - return this.configurationService.getValue(ChatConfiguration.ChatCustomizationHarnessSelectorEnabled) !== false; + return false; //this.configurationService.getValue(ChatConfiguration.ChatCustomizationHarnessSelectorEnabled) !== false; } /** @@ -516,14 +515,9 @@ export class AICustomizationManagementEditor extends EditorPane { * section, the first visible section is selected instead. */ private rebuildVisibleSections(): void { - let hidden: Set; - if (this.isHarnessSelectorEnabled) { - const activeId = this.harnessService.activeHarness.get(); - const descriptor = this.harnessService.findHarnessById(activeId); - hidden = new Set(descriptor?.hiddenSections ?? []); - } else { - hidden = new Set(); // Local harness has no hidden sections - } + const activeId = this.harnessService.activeHarness.get(); + const descriptor = this.harnessService.findHarnessById(activeId); + const hidden = new Set(descriptor?.hiddenSections ?? []); this.sections.length = 0; for (const s of this.allSections) { @@ -596,15 +590,8 @@ export class AICustomizationManagementEditor extends EditorPane { // React to harness changes — rebuild visible sections and refresh counts. // Also track availableHarnesses to handle agent registration/unregistration. this.editorDisposables.add(autorun(reader => { - const available = this.harnessService.availableHarnesses.read(reader); + this.harnessService.availableHarnesses.read(reader); const activeId = this.harnessService.activeHarness.read(reader); - - // If the active harness is no longer available, fall back to the default - if (!available.some(h => h.id === activeId) && available.length > 0) { - this.harnessService.setActiveHarness(available[0].id); - return; // setActiveHarness will trigger another autorun cycle - } - this.harnessContextKey.set(activeId); this.rebuildVisibleSections(); this.ensureHarnessDropdown(); @@ -621,19 +608,7 @@ export class AICustomizationManagementEditor extends EditorPane { this._previousActiveHarnessId = activeId; })); - // When the harness selector setting is off, lock to Local harness. - // In Sessions (single CLI harness) the dropdown is already hidden and - // setActiveHarness(VSCode) is a safe no-op since the CLI harness - // remains active — filtering stays correct for that window. - if (!this.isHarnessSelectorEnabled) { - this.harnessService.setActiveHarness(SessionType.Local); - } this.editorDisposables.add(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ChatConfiguration.ChatCustomizationHarnessSelectorEnabled)) { - if (!this.isHarnessSelectorEnabled) { - this.harnessService.setActiveHarness(SessionType.Local); - } - } if (e.affectsConfiguration(ChatConfiguration.ChatCustomizationsStructuredPreviewEnabled)) { this.onStructuredPreviewSettingChanged(); } @@ -658,7 +633,7 @@ export class AICustomizationManagementEditor extends EditorPane { })); // Harness dropdown (shown when multiple harnesses available) - this.createHarnessDropdown(headerRow); + //this.createHarnessDropdown(headerRow); this.updateHomeButtonStyle(); } @@ -747,7 +722,7 @@ export class AICustomizationManagementEditor extends EditorPane { const actions = harnesses.map(h => { const action = new Action(h.id, h.label, ThemeIcon.asClassName(h.icon), true, () => { - this.harnessService.setActiveHarness(h.id); + this.harnessService.setActiveSession(this.harnessService.getSessionResourceForHarness(h.id)); }); action.checked = h.id === activeId; return action; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts index 519a41f869a18..d61da34576d51 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts @@ -8,10 +8,12 @@ import { CustomizationHarnessServiceBase, ICustomizationHarnessService, createVSCodeHarnessDescriptor, + } from '../../common/customizationHarnessService.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { BUILTIN_STORAGE } from '../../common/aiCustomizationWorkspaceService.js'; import { SessionType } from '../../common/chatSessionsService.js'; +import { URI } from '../../../../../base/common/uri.js'; /** * Core implementation of the customization harness service. @@ -21,7 +23,7 @@ import { SessionType } from '../../common/chatSessionsService.js'; */ class CustomizationHarnessService extends CustomizationHarnessServiceBase { constructor( - @IPromptsService promptsService: IPromptsService + @IPromptsService promptsService: IPromptsService, ) { const localExtras = [PromptsStorage.extension, BUILTIN_STORAGE]; super( @@ -30,6 +32,19 @@ class CustomizationHarnessService extends CustomizationHarnessServiceBase { promptsService, ); } + + override getSessionResourceForHarness(sessionType: string): URI { + // const lastUsedSession = this.agentSessionsService.model.sessions + // .filter(session => session.providerType === sessionType) + // .sort((a, b) => (b.timing.lastRequestEnded ?? b.timing.created) - (a.timing.lastRequestEnded ?? a.timing.created)) + // .at(0); + + // if (lastUsedSession) { + // return lastUsedSession.resource; + // } + + return super.getSessionResourceForHarness(sessionType); + } } registerSingleton(ICustomizationHarnessService, CustomizationHarnessService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts index 53bbd5bd953aa..f9e76c3372279 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts @@ -981,9 +981,10 @@ export class PluginListWidget extends Disposable { if (!provider) { return []; } + const sessionResource = this.harnessService.activeSessionResource.get(); try { - const provided = await provider.provideChatSessionCustomizations(CancellationToken.None) ?? []; + const provided = await provider.provideChatSessionCustomizations(sessionResource, CancellationToken.None) ?? []; return provided.filter(item => isPluginCustomizationItem(item) && (!query diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts index 3769441025ef0..9fe24608489b0 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts @@ -7,6 +7,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Event } from '../../../../../base/common/event.js'; import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; import { OS } from '../../../../../base/common/platform.js'; +import { URI } from '../../../../../base/common/uri.js'; import { basename, dirname, isEqualOrParent } from '../../../../../base/common/resources.js'; import { localize } from '../../../../../nls.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; @@ -43,7 +44,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt ); } - async provideChatSessionCustomizations(token: CancellationToken): Promise { + async provideChatSessionCustomizations(_sessionResource: URI, token: CancellationToken): Promise { const itemSets = await Promise.all([ this.provideCustomizations(PromptsType.agent, token), this.provideCustomizations(PromptsType.skill, token), diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 2582399b8cfcf..fb644550ec019 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -564,15 +564,15 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ if (chatOptions) { let attachedContext = chatOptions.attachedContext; - const resource = URI.revive(chatOptions.resource); - const ref = await chatService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None, 'ChatSessionsContribution#sendPrompt'); + const sessionResource = URI.revive(chatOptions.resource); + const ref = await chatService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, CancellationToken.None, 'ChatSessionsContribution#sendPrompt'); try { - const promptFile = await resolvePromptSlashCommand(chatOptions.prompt, contribution.type, customizationHarnessService, toolsService); + const promptFile = await resolvePromptSlashCommand(chatOptions.prompt, sessionResource, customizationHarnessService, toolsService); if (promptFile) { attachedContext = [promptFile, ...(attachedContext ?? [])]; } - const result = await chatService.sendRequest(resource, chatOptions.prompt, { agentIdSilent: type, attachedContext }); + const result = await chatService.sendRequest(sessionResource, chatOptions.prompt, { agentIdSilent: type, attachedContext }); if (result.kind === 'queued') { await result.deferred; } else if (result.kind === 'sent') { @@ -1354,7 +1354,7 @@ export async function openChatSession(accessor: ServicesAccessor, openOptions: N const toolsService = accessor.get(ILanguageModelToolsService); // Determine resource to open - const resource = getResourceForNewChatSession(openOptions); + const sessionResource = getResourceForNewChatSession(openOptions); // Open chat session try { @@ -1364,7 +1364,7 @@ export async function openChatSession(accessor: ServicesAccessor, openOptions: N if (openOptions.type === AgentSessionProviders.Local) { await view.widget.clear(); } else { - await view.loadSession(resource); + await view.loadSession(sessionResource); } view.focus(); break; @@ -1383,9 +1383,9 @@ export async function openChatSession(accessor: ServicesAccessor, openOptions: N if (!activeEditor || !(activeEditor instanceof ChatEditorInput)) { throw new Error('No active chat editor to replace'); } - await editorService.replaceEditors([{ editor: activeEditor, replacement: { resource, options } }], editorGroupService.activeGroup); + await editorService.replaceEditors([{ editor: activeEditor, replacement: { resource: sessionResource, options } }], editorGroupService.activeGroup); } else { - await editorService.openEditor({ resource, options }); + await editorService.openEditor({ resource: sessionResource, options }); } break; } @@ -1402,15 +1402,15 @@ export async function openChatSession(accessor: ServicesAccessor, openOptions: N // Set initial session options on the model before sending the request, // so that the contributed session provider can read them. if (chatSendOptions.initialSessionOptions) { - chatSessionService.updateSessionOptions(resource, normalizeSessionOptions(chatSendOptions.initialSessionOptions)); + chatSessionService.updateSessionOptions(sessionResource, normalizeSessionOptions(chatSendOptions.initialSessionOptions)); } let attachedContext = chatSendOptions.attachedContext; - const promptFile = await resolvePromptSlashCommand(chatSendOptions.prompt, openOptions.type, customizationHarnessService, toolsService); + const promptFile = await resolvePromptSlashCommand(chatSendOptions.prompt, sessionResource, customizationHarnessService, toolsService); if (promptFile) { attachedContext = [promptFile, ...(attachedContext ?? [])]; } - await chatService.sendRequest(resource, chatSendOptions.prompt, { agentIdSilent: openOptions.type, attachedContext }); + await chatService.sendRequest(sessionResource, chatSendOptions.prompt, { agentIdSilent: openOptions.type, attachedContext }); } catch (e) { logService.error(`Failed to send initial request to '${openOptions.type}' chat session with contextOptions: ${JSON.stringify(chatSendOptions)}`, e); } @@ -1442,12 +1442,12 @@ function normalizeSessionOptions(options: ReadonlyChatSessionOptionsMap | Readon /** * Returns the variable entry for a slash command if the prompt starts with a slash command that can be resolved to a prompt file, otherwise returns undefined. */ -async function resolvePromptSlashCommand(prompt: string, sessionType: string, customizationHarnessService: ICustomizationHarnessService, toolsService: ILanguageModelToolsService): Promise { +async function resolvePromptSlashCommand(prompt: string, sessionResource: URI, customizationHarnessService: ICustomizationHarnessService, toolsService: ILanguageModelToolsService): Promise { const slashMatch = prompt.match(slashReg); // starts with a slash command, add the corresponding prompt file to the context if it exists if (slashMatch) { // need to resolve the slash command to get the prompt file - const slashCommand = await customizationHarnessService.resolvePromptSlashCommand(slashMatch[1], sessionType, CancellationToken.None); + const slashCommand = await customizationHarnessService.resolvePromptSlashCommand(slashMatch[1], sessionResource, CancellationToken.None); if (slashCommand) { const parseResult = slashCommand.parsedPromptFile; // add the prompt file to the context diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 0d5a310ac203a..c0b5a1565a321 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -2360,10 +2360,8 @@ export class ChatWidget extends Disposable implements IChatWidget { // Track them now so tip exclusions still update for commands like /init. this.chatTipService.recordSlashCommandUsage(agentSlashPromptPart.name); - const sessionType = getChatSessionType(sessionResource); - // need to resolve the slash command to get the prompt file - const slashCommand = await this.customizationHarnessService.resolvePromptSlashCommand(agentSlashPromptPart.name, sessionType, CancellationToken.None); + const slashCommand = await this.customizationHarnessService.resolvePromptSlashCommand(agentSlashPromptPart.name, sessionResource, CancellationToken.None); if (!slashCommand) { return true; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts index 019509b16dbd2..68c48532cd3e8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts @@ -247,7 +247,7 @@ class SlashCommandCompletions extends Disposable { } const currentSessionType = getChatSessionType(widget.viewModel.model.sessionResource); - const promptCommands = await this.harnessService.getSlashCommands(currentSessionType, token); + const promptCommands = await this.harnessService.getSlashCommands(widget.viewModel.model.sessionResource, token); if (promptCommands.length === 0) { return null; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts index cd9d38545056a..cc4f48e683cf3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts @@ -324,7 +324,7 @@ class InputEditorDecorations extends Disposable { const slashPromptPart = parsedRequest.find((p): p is ChatRequestSlashPromptPart => p instanceof ChatRequestSlashPromptPart); // first, fetch all async context - const promptSlashCommand = slashPromptPart ? await this.customizationHarnessService.resolvePromptSlashCommand(slashPromptPart.name, getChatSessionType(viewModel.sessionResource), token) : undefined; + const promptSlashCommand = slashPromptPart ? await this.customizationHarnessService.resolvePromptSlashCommand(slashPromptPart.name, viewModel.sessionResource, token) : undefined; if (token.isCancellationRequested) { // a new update came in while we were waiting return; diff --git a/src/vs/workbench/contrib/chat/common/chatModes.ts b/src/vs/workbench/contrib/chat/common/chatModes.ts index bf5366210de7b..dfe40a036aeca 100644 --- a/src/vs/workbench/contrib/chat/common/chatModes.ts +++ b/src/vs/workbench/contrib/chat/common/chatModes.ts @@ -219,7 +219,7 @@ class ChatModes extends Disposable implements IChatModes { private async computeCustomAgents(): Promise { const useHarness = this.useChatSessionCustomizationsForCustomAgents(); if (useHarness) { - return await this.customizationHarnessService.getCustomAgents(getChatSessionType(this.sessionResource), CancellationToken.None); + return await this.customizationHarnessService.getCustomAgents(this.sessionResource, CancellationToken.None); } const sessionType = getChatSessionType(this.sessionResource); return (await this.promptsService.getCustomAgents(CancellationToken.None)).filter(mode => matchesSessionType(mode.sessionTypes, sessionType)); diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 280adfe670e1a..2909add655d72 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../base/common/codicons.js'; -import { IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js'; +import { derived, IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { joinPath } from '../../../../base/common/resources.js'; @@ -21,6 +21,7 @@ import { SessionType } from './chatSessionsService.js'; import { CustomAgent } from './promptSyntax/service/promptsServiceImpl.js'; import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; import { getCanonicalPluginCommandId } from './plugins/agentPluginService.js'; +import { getChatSessionType, LocalChatSessionUri } from './model/chatUri.js'; export const ICustomizationHarnessService = createDecorator('customizationHarnessService'); @@ -205,8 +206,13 @@ export interface ICustomizationItemProvider { readonly onDidChange: Event; /** * Provide the customization items this harness supports. + * + * @param sessionResource URI of the chat session whose + * customizations should be included. Providers that surface + * session-scoped state (e.g. an agent host) should read from + * this session. */ - provideChatSessionCustomizations(token: CancellationToken): Promise; + provideChatSessionCustomizations(sessionResource: URI, token: CancellationToken): Promise; } /** @@ -233,6 +239,11 @@ export interface ICustomizationSyncProvider { export interface ICustomizationHarnessService { readonly _serviceBrand: undefined; + /** + * The currently active chat session resource. + */ + readonly activeSessionResource: IObservable; + /** * The currently active harness. */ @@ -251,10 +262,10 @@ export interface ICustomizationHarnessService { findHarnessById(sessionType: string): IHarnessDescriptor | undefined; /** - * Changes the active harness. The new id must be present in + * Changes the active session. The new session's type must be present in * `availableHarnesses`. */ - setActiveHarness(sessionType: string): void; + setActiveSession(sessionResource: URI): void; /** * Convenience: returns the storage source filter for the active harness @@ -289,21 +300,39 @@ export interface ICustomizationHarnessService { * Returns the prompt and skill slash commands for the given session type. * Provider-backed harnesses contribute their own items directly; the default * VS Code harness falls back to the core prompts service. + * + * @param sessionResource URI of the chat session whose customizations + * should be considered. Forwarded to the underlying + * {@link ICustomizationItemProvider.provideChatSessionCustomizations}. */ - getSlashCommands(sessionType: string, token: CancellationToken): Promise; + getSlashCommands(sessionResource: URI, token: CancellationToken): Promise; /** * Returns the custom agents for the given session type. * Provider-backed harnesses select items via their own provider and resolve * details via the core prompts service. + * + * @param sessionResource URI of the chat session whose customizations + * should be considered. Forwarded to the underlying + * {@link ICustomizationItemProvider.provideChatSessionCustomizations}. */ - getCustomAgents(sessionType: string, token: CancellationToken): Promise; + getCustomAgents(sessionResource: URI, token: CancellationToken): Promise; /** * Resolves a slash command to its full metadata, including the parsed prompt file for prompt commands. * Provider-backed harnesses resolve their own items directly; the default VS Code harness falls back to the core prompts service. + * + * @param sessionResource URI of the chat session whose customizations + * should be considered when looking up the slash command. + */ + resolvePromptSlashCommand(name: string, sessionResource: URI, token: CancellationToken): Promise; + + /** + * Returns the best session resource to use for a harness lookup. + * Implementations should prefer the most recently used session for the + * given session type and fall back to an untitled session resource. */ - resolvePromptSlashCommand(name: string, sessionType: string, token: CancellationToken): Promise; + getSessionResourceForHarness(sessionType: string): URI; } /** @@ -513,7 +542,10 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer private readonly _providerListeners: IDisposable[] = []; private _isDisposed = false; - private readonly _activeHarness: ISettableObservable; + private readonly _activeSessionResource: ISettableObservable; + readonly activeSessionResource: IObservable; + + private readonly _activeHarness: IObservable; readonly activeHarness: IObservable; private readonly _staticHarnesses: readonly IHarnessDescriptor[]; @@ -528,7 +560,9 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer ) { this._staticHarnesses = staticHarnesses; this.promptsService = promptsService; - this._activeHarness = observableValue(this, defaultHarness); + this._activeSessionResource = observableValue(this, this.getSessionResourceForHarness(defaultHarness)); + this.activeSessionResource = this._activeSessionResource; + this._activeHarness = derived(this, reader => getChatSessionType(this._activeSessionResource.read(reader))); this.activeHarness = this._activeHarness; this._availableHarnesses = observableValue(this, [...this._staticHarnesses]); this.availableHarnesses = this._availableHarnesses; @@ -592,14 +626,6 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer if (idx >= 0) { this._externalHarnesses.splice(idx, 1); this._refreshAvailableHarnesses(); - // If the removed harness was active, only fall back when no - // remaining harness (e.g. the restored static one) shares the id. - if (this._activeHarness.get() === descriptor.id) { - const all = this._getAllHarnesses(); - if (!all.some(h => h.id === descriptor.id) && all.length > 0) { - this._activeHarness.set(all[0].id, undefined); - } - } } } }; @@ -609,40 +635,31 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer return this._getAllHarnesses().find(h => h.id === id); } - setActiveHarness(id: string): void { - const harness = this.findHarnessById(id); - if (harness) { - this._activeHarness.set(id, undefined); - } + setActiveSession(sessionResource: URI): void { + this._activeSessionResource.set(sessionResource, undefined); } getStorageSourceFilter(type: PromptsType): IStorageSourceFilter { const activeId = this._activeHarness.get(); - const all = this._getAllHarnesses(); - if (all.length === 0) { - return EMPTY_FILTER; - } - const descriptor = all.find(h => h.id === activeId) ?? all[0]; + const descriptor = this.findHarnessById(activeId); return descriptor?.getStorageSourceFilter(type) ?? EMPTY_FILTER; } getActiveDescriptor(): IHarnessDescriptor { const activeId = this._activeHarness.get(); - const all = this._getAllHarnesses(); - if (all.length === 0) { - return EMPTY_DESCRIPTOR; - } - return all.find(h => h.id === activeId) ?? all[0]; + const descriptor = this.findHarnessById(activeId); + return descriptor ?? EMPTY_DESCRIPTOR; } - async getSlashCommands(sessionType: string, token: CancellationToken): Promise { + async getSlashCommands(sessionResource: URI, token: CancellationToken): Promise { + const sessionType = getChatSessionType(sessionResource); const harness = this.findHarnessById(sessionType); if (!harness || !harness.itemProvider) { const commands = await this.promptsService.getPromptSlashCommands(token); return commands.filter(command => matchesSessionType(command.sessionTypes, sessionType)); } - const items = await harness.itemProvider.provideChatSessionCustomizations(token); + const items = await harness.itemProvider.provideChatSessionCustomizations(sessionResource, token); if (!items) { return []; } @@ -670,14 +687,15 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer return result; } - async getCustomAgents(sessionType: string, token: CancellationToken): Promise { + async getCustomAgents(sessionResource: URI, token: CancellationToken): Promise { + const sessionType = getChatSessionType(sessionResource); const harness = this.findHarnessById(sessionType); if (!harness || !harness.itemProvider) { const allAgents = await this.promptsService.getCustomAgents(token); return allAgents.filter(agent => matchesSessionType(agent.sessionTypes, sessionType)); } - const items = await harness.itemProvider.provideChatSessionCustomizations(token); + const items = await harness.itemProvider.provideChatSessionCustomizations(sessionResource, token); if (!items) { return []; } @@ -712,8 +730,8 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer return result; } - public async resolvePromptSlashCommand(name: string, sessionType: string, token: CancellationToken): Promise { - const commands = await this.getSlashCommands(sessionType, token); + public async resolvePromptSlashCommand(name: string, sessionResource: URI, token: CancellationToken): Promise { + const commands = await this.getSlashCommands(sessionResource, token); const command = commands.find(cmd => cmd.name === name); if (command) { const parsedPromptFile = await this.promptsService.parseNew(command.uri, token); @@ -725,6 +743,14 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer } return undefined; } + + getSessionResourceForHarness(sessionType: string): URI { + if (sessionType === SessionType.Local) { + return LocalChatSessionUri.getNewSessionUri(); + } + + return URI.from({ scheme: sessionType, path: '/untitled-2' }); + } } // #endregion diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts index 2b31fae64130d..6914b286b78a8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts @@ -10,7 +10,7 @@ import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; -import { ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { derived, IObservable, ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; @@ -22,6 +22,7 @@ import { ContributionEnablementState } from '../../../common/enablement.js'; import { IAgentPluginService, type IAgentPlugin } from '../../../common/plugins/agentPluginService.js'; import { IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; +import { getChatSessionType } from '../../../common/model/chatUri.js'; suite('AICustomizationItemsModel', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -30,8 +31,8 @@ suite('AICustomizationItemsModel', () => { let disposables: DisposableStore; let instaService: TestInstantiationService; - - let activeHarness: ISettableObservable; + let activeSessionResource: ISettableObservable; + let activeHarness: IObservable; let availableHarnesses: ISettableObservable; let descriptorA: IHarnessDescriptor; let descriptorB: IHarnessDescriptor; @@ -61,19 +62,20 @@ suite('AICustomizationItemsModel', () => { const providerA: ICustomizationItemProvider = { onDidChange: providerA_didChange.event, - provideChatSessionCustomizations: (_token: CancellationToken) => { + provideChatSessionCustomizations: (sessionResource: URI, token: CancellationToken) => { providerA_callCount++; return Promise.resolve(providerA_items.slice()); }, }; const providerB: ICustomizationItemProvider = { onDidChange: Event.None, - provideChatSessionCustomizations: (_token: CancellationToken) => Promise.resolve([]), + provideChatSessionCustomizations: (sessionResource: URI, token: CancellationToken) => Promise.resolve([]), }; descriptorA = createDescriptor('A', providerA); descriptorB = createDescriptor('B', providerB); - activeHarness = observableValue('activeHarness', 'A'); + activeSessionResource = observableValue('activeSessionResource', URI.parse(`A:///session`)); + activeHarness = derived(reader => getChatSessionType(activeSessionResource.read(reader))); availableHarnesses = observableValue('availableHarnesses', [descriptorA, descriptorB]); plugins = observableValue('plugins', []); @@ -110,9 +112,12 @@ suite('AICustomizationItemsModel', () => { }); instaService.stub(ICustomizationHarnessService, { + activeSessionResource, activeHarness, availableHarnesses, - setActiveHarness: (id: string) => activeHarness.set(id, undefined), + setActiveSession: (sessionResource: URI) => { + activeSessionResource.set(sessionResource, undefined); + }, getStorageSourceFilter: () => ({ sources: [] }), getActiveDescriptor: () => availableHarnesses.get().find(d => d.id === activeHarness.get())!, findHarnessById: (id: string) => availableHarnesses.get().find(d => d.id === id), @@ -188,31 +193,12 @@ suite('AICustomizationItemsModel', () => { model.getItems(AICustomizationManagementSection.Agents); await timeout(0); const sourceA = model.getActiveItemSource(); - activeHarness.set('B', undefined); + activeSessionResource.set(URI.parse('B://session'), undefined); await timeout(0); const sourceB = model.getActiveItemSource(); assert.notStrictEqual(sourceA, sourceB); }); - test('source cache is keyed by descriptor identity (not id) — re-registration produces a fresh source', async () => { - const model = disposables.add(instaService.createInstance(AICustomizationItemsModel)); - model.getItems(AICustomizationManagementSection.Agents); - await timeout(0); - const sourceA1 = model.getActiveItemSource(); - - // Replace descriptor A with a fresh descriptor that re-uses the same id. - const replacementProvider: ICustomizationItemProvider = { - onDidChange: Event.None, - provideChatSessionCustomizations: async () => [], - }; - const replacementA = createDescriptor('A', replacementProvider); - availableHarnesses.set([replacementA, descriptorB], undefined); - await timeout(0); - - const sourceA2 = model.getActiveItemSource(); - assert.notStrictEqual(sourceA1, sourceA2); - }); - test('preserves provider-supplied plugin storage when pluginUri is omitted', async () => { providerA_items = [{ uri: URI.parse('agent-host://test-authority/plugins/my-plugin/skills/my-skill/SKILL.md'), @@ -407,7 +393,7 @@ suite('AICustomizationItemsModel', () => { }; const providerWithSync: ICustomizationItemProvider = { onDidChange: providerA_didChange.event, - provideChatSessionCustomizations: (_token: CancellationToken) => { + provideChatSessionCustomizations: (sessionResource: URI, token: CancellationToken) => { providerA_callCount++; return Promise.resolve(providerA_items.slice()); }, @@ -447,7 +433,7 @@ suite('AICustomizationItemsModel', () => { }; const providerWithSync: ICustomizationItemProvider = { onDidChange: providerA_didChange.event, - provideChatSessionCustomizations: (_token: CancellationToken) => { + provideChatSessionCustomizations: (sessionResource: URI, token: CancellationToken) => { providerA_callCount++; return Promise.resolve(providerA_items.slice()); }, @@ -483,7 +469,7 @@ suite('AICustomizationItemsModel', () => { const provider: ICustomizationItemProvider = { onDidChange: providerDidChange.event, - provideChatSessionCustomizations: (_token: CancellationToken) => Promise.resolve(providerItems.slice()), + provideChatSessionCustomizations: (sessionResource: URI, token: CancellationToken) => Promise.resolve(providerItems.slice()), }; const descriptor: IHarnessDescriptor = { id: 'A', @@ -492,7 +478,7 @@ suite('AICustomizationItemsModel', () => { getStorageSourceFilter: (): IStorageSourceFilter => ({ sources: [PromptsStorage.local, PromptsStorage.user] }), itemProvider: provider, }; - const activeHarness = observableValue('activeHarness', 'A'); + const sessionResource = URI.parse('A:///active-session'); const availableHarnesses = observableValue('availableHarnesses', [descriptor]); instaService = workbenchInstantiationService({}, disposables); @@ -524,10 +510,15 @@ suite('AICustomizationItemsModel', () => { setOverrideProjectRoot: () => { }, clearOverrideProjectRoot: () => { }, }); + const activeSessionResource = observableValue('activeSessionResource', sessionResource); + const activeHarness = derived(reader => getChatSessionType(activeSessionResource.read(reader))); instaService.stub(ICustomizationHarnessService, { + activeSessionResource, activeHarness, availableHarnesses, - setActiveHarness: (id: string) => activeHarness.set(id, undefined), + setActiveSession: (sessionResource: URI) => { + activeSessionResource.set(sessionResource, undefined); + }, getStorageSourceFilter: () => ({ sources: [] }), getActiveDescriptor: () => availableHarnesses.get().find(d => d.id === activeHarness.get())!, findHarnessById: (id: string) => availableHarnesses.get().find(d => d.id === id), diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts index 663a92bfdcc2b..be7d693235f0b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts @@ -4,10 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { URI } from '../../../../../../base/common/uri.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Event } from '../../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; -import { observableValue } from '../../../../../../base/common/observable.js'; +import { derived, observableValue } from '../../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; @@ -18,6 +19,7 @@ import { extractExtensionIdFromPath, getCustomizationSecondaryText, truncateToFi import { AICustomizationManagementSection, IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js'; import { ICustomizationHarnessService, IHarnessDescriptor } from '../../../common/customizationHarnessService.js'; import { ContributionEnablementState } from '../../../common/enablement.js'; +import { getChatSessionType } from '../../../common/model/chatUri.js'; import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; import { IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; @@ -165,7 +167,7 @@ suite('aiCustomizationListWidget', () => { getStorageSourceFilter: (): IStorageSourceFilter => ({ sources: [PromptsStorage.local, PromptsStorage.user] }), itemProvider: { onDidChange: Event.None, - provideChatSessionCustomizations: (_token: CancellationToken) => Promise.resolve(undefined), + provideChatSessionCustomizations: (sessionResource: URI, token: CancellationToken) => Promise.resolve(undefined), }, }; @@ -203,10 +205,14 @@ suite('aiCustomizationListWidget', () => { clearOverrideProjectRoot: () => { }, }); + const activeSessionResource = observableValue('test', URI.parse('test:///session')); + const activeHarness = derived(reader => getChatSessionType(activeSessionResource.read(reader))); + instaService.stub(ICustomizationHarnessService, { - activeHarness: observableValue('test', 'test'), + activeSessionResource, + activeHarness, availableHarnesses: observableValue('test', [descriptor]), - setActiveHarness: () => { }, + setActiveSession: () => { }, getStorageSourceFilter: () => ({ sources: [] }), getActiveDescriptor: () => descriptor, findHarnessById: (id) => id === descriptor.id ? descriptor : undefined, diff --git a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts index e43f1ca88e854..2eb455c9f1663 100644 --- a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts @@ -28,7 +28,15 @@ suite('CustomizationHarnessService', () => { return service; } + const testSessionType1 = 'test-session-type1'; + //const testSessionType2 = 'test-session-type2'; + const testSessionResource1 = URI.parse('test-session-type1://session1'); + const testSessionResource2 = URI.parse('test-session-type2://session2'); + suite('registerExternalHarness', () => { + + + test('forwards item provider changes via onDidChangeSlashCommands with sessionType', () => { const service = createService(); const emitter = new Emitter(); @@ -41,7 +49,7 @@ suite('CustomizationHarnessService', () => { getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), itemProvider: { onDidChange: emitter.event, - provideChatSessionCustomizations: async () => [], + provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], }, }; @@ -67,7 +75,7 @@ suite('CustomizationHarnessService', () => { getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), itemProvider: { onDidChange: emitter.event, - provideChatSessionCustomizations: async () => [], + provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], }, }; @@ -94,7 +102,7 @@ suite('CustomizationHarnessService', () => { getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), itemProvider: { onDidChange: emitter.event, - provideChatSessionCustomizations: async () => [], + provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], }, }; @@ -116,7 +124,7 @@ suite('CustomizationHarnessService', () => { getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), itemProvider: { onDidChange: emitter.event, - provideChatSessionCustomizations: async () => [], + provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], }, }; @@ -127,7 +135,7 @@ suite('CustomizationHarnessService', () => { assert.strictEqual(service.availableHarnesses.get().length, 1); }); - test('falls back to first harness when active external harness is removed', () => { + test.skip('falls back to first harness when active external harness is removed', () => { const service = createService(); const emitter = new Emitter(); store.add(emitter); @@ -138,12 +146,13 @@ suite('CustomizationHarnessService', () => { getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), itemProvider: { onDidChange: emitter.event, - provideChatSessionCustomizations: async () => [], + provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], }, }; + const activeSessionResource = URI.parse('test-ext://session'); const reg = service.registerExternalHarness(externalDescriptor); - service.setActiveHarness('test-ext'); + service.setActiveSession(activeSessionResource); assert.strictEqual(service.activeHarness.get(), 'test-ext'); reg.dispose(); @@ -161,12 +170,13 @@ suite('CustomizationHarnessService', () => { getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), itemProvider: { onDidChange: emitter.event, - provideChatSessionCustomizations: async () => [], + provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], }, }; + const activeSessionResource = URI.parse('test-ext://session'); store.add(service.registerExternalHarness(externalDescriptor)); - service.setActiveHarness('test-ext'); + service.setActiveSession(activeSessionResource); assert.strictEqual(service.activeHarness.get(), 'test-ext'); const activeDescriptor = service.getActiveDescriptor(); @@ -187,12 +197,13 @@ suite('CustomizationHarnessService', () => { getStorageSourceFilter: () => customFilter, itemProvider: { onDidChange: emitter.event, - provideChatSessionCustomizations: async () => [], + provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], }, }; + const activeSessionResource = URI.parse('test-ext://session'); store.add(service.registerExternalHarness(externalDescriptor)); - service.setActiveHarness('test-ext'); + service.setActiveSession(activeSessionResource); assert.deepStrictEqual(service.getStorageSourceFilter(PromptsType.agent), customFilter); }); @@ -216,11 +227,14 @@ suite('CustomizationHarnessService', () => { getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), itemProvider, }; + const activeSessionResource = URI.parse('test-ext://session'); + + const testSessionResource = URI.parse('test-ext://session'); store.add(service.registerExternalHarness(externalDescriptor)); - service.setActiveHarness('test-ext'); + service.setActiveSession(activeSessionResource); - const items = await service.getActiveDescriptor().itemProvider!.provideChatSessionCustomizations(CancellationToken.None); + const items = await service.getActiveDescriptor().itemProvider!.provideChatSessionCustomizations(testSessionResource, CancellationToken.None); assert.strictEqual(items?.length, 1); assert.strictEqual(items![0].name, 'Test Skill'); assert.strictEqual(items![0].type, 'skill'); @@ -239,12 +253,13 @@ suite('CustomizationHarnessService', () => { getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), itemProvider: { onDidChange: emitter.event, - provideChatSessionCustomizations: async () => [], + provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], }, }; store.add(service.registerExternalHarness(externalDescriptor)); - service.setActiveHarness('test-ext'); + const activeSessionResource = URI.parse('test-ext://session'); + service.setActiveSession(activeSessionResource); const descriptor = service.getActiveDescriptor(); assert.deepStrictEqual(descriptor.hiddenSections, ['agents', 'prompts']); @@ -273,7 +288,7 @@ suite('CustomizationHarnessService', () => { getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), itemProvider: { onDidChange: emitter.event, - provideChatSessionCustomizations: async () => [], + provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], }, }; @@ -307,7 +322,7 @@ suite('CustomizationHarnessService', () => { getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), itemProvider: { onDidChange: emitter.event, - provideChatSessionCustomizations: async () => [], + provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], }, }; @@ -341,12 +356,13 @@ suite('CustomizationHarnessService', () => { getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), itemProvider: { onDidChange: emitter.event, - provideChatSessionCustomizations: async () => [], + provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], }, }; + const sessionResource = URI.parse('cli://session'); const reg = service.registerExternalHarness(externalDescriptor); - service.setActiveHarness('cli'); + service.setActiveSession(sessionResource); assert.strictEqual(service.activeHarness.get(), 'cli'); reg.dispose(); @@ -361,6 +377,7 @@ suite('CustomizationHarnessService', () => { const testSessionType = 'test-session-type'; + const testSessionResource = URI.parse('test-session-type://session'); const emitter = new Emitter(); store.add(emitter); @@ -371,7 +388,7 @@ suite('CustomizationHarnessService', () => { getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), itemProvider: { onDidChange: emitter.event, - provideChatSessionCustomizations: async () => [ + provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [ { uri: URI.parse('file:///workspace/.test/prompts/fix.prompt.md'), type: PromptsType.prompt, name: 'fix', description: 'Fix something', extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, { uri: URI.parse('file:///workspace/.test/skills/lint/SKILL.md'), type: PromptsType.skill, name: 'lint', description: 'Lint skill', extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, { uri: URI.parse('file:///workspace/.test/instructions/rule.instructions.md'), type: PromptsType.instructions, name: 'rule', description: 'Ignore me', extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, @@ -380,7 +397,7 @@ suite('CustomizationHarnessService', () => { }, }); - const commands = await service.getSlashCommands(testSessionType, CancellationToken.None); + const commands = await service.getSlashCommands(testSessionResource, CancellationToken.None); assert.deepStrictEqual(commands.map(command => ({ name: command.name, type: command.type })), [ { name: 'fix', type: PromptsType.prompt }, { name: 'lint', type: PromptsType.skill }, @@ -390,6 +407,8 @@ suite('CustomizationHarnessService', () => { test('falls back to promptsService when the active harness has no provider', async () => { const testSessionType = 'test-session-type'; + const testSessionResource = URI.parse('test-session-type://session'); + const otherSessionResource = URI.parse('other-session-type://session'); const promptsService = new class extends MockPromptsService { override async getPromptSlashCommands() { return [ @@ -402,14 +421,14 @@ suite('CustomizationHarnessService', () => { const service = new CustomizationHarnessServiceBase([createVSCodeHarnessDescriptor([PromptsStorage.extension])], SessionType.Local, promptsService); store.add(service); { - const commands = await service.getSlashCommands(testSessionType, CancellationToken.None); + const commands = await service.getSlashCommands(testSessionResource, CancellationToken.None); assert.deepStrictEqual(commands.map(command => ({ name: command.name, type: command.type, userInvocable: command.userInvocable, sessionTypes: command.sessionTypes })), [ { name: 'explain', type: PromptsType.prompt, userInvocable: false, sessionTypes: [testSessionType] }, { name: 'review', type: PromptsType.skill, userInvocable: true, sessionTypes: undefined }, ]); } { - const commands = await service.getSlashCommands(SessionType.Local, CancellationToken.None); + const commands = await service.getSlashCommands(otherSessionResource, CancellationToken.None); assert.deepStrictEqual(commands.map(command => ({ name: command.name, type: command.type, userInvocable: command.userInvocable, sessionTypes: command.sessionTypes })), [ { name: 'review', type: PromptsType.skill, userInvocable: true, sessionTypes: undefined }, ]); @@ -430,23 +449,21 @@ suite('CustomizationHarnessService', () => { }); test('falls back to promptsService and filters by session type', async () => { - const testSessionType = 'test-session-type'; const promptsService = new MockPromptsService(); promptsService.setCustomModes([ - createAgent('matching', 'file:///workspace/.github/agents/matching.agent.md', [testSessionType], true), + createAgent('matching', 'file:///workspace/.github/agents/matching.agent.md', [testSessionType1], true), createAgent('global', 'file:///workspace/.github/agents/global.agent.md', undefined, true), createAgent('other', 'file:///workspace/.github/agents/other.agent.md', ['other-session'], true), ]); const service = new CustomizationHarnessServiceBase([createVSCodeHarnessDescriptor([PromptsStorage.extension])], SessionType.Local, promptsService); store.add(service); - const agents = await service.getCustomAgents(testSessionType, CancellationToken.None); + const agents = await service.getCustomAgents(testSessionResource1, CancellationToken.None); assert.deepStrictEqual(agents.map(agent => agent.name), ['matching', 'global']); }); test('uses provider item URIs to scope resolved custom agents', async () => { - const testSessionType1 = 'test-session-type1'; - const testSessionType2 = 'test-session-type2'; + const promptsService = new MockPromptsService(); promptsService.setCustomModes([ createAgent('selected', 'file:///workspace/.test/agents/selected.agent.md', undefined, true), @@ -462,7 +479,7 @@ suite('CustomizationHarnessService', () => { getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), itemProvider: { onDidChange: emitter.event, - provideChatSessionCustomizations: async () => [ + provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [ { uri: URI.parse('file:///workspace/.test/agents/enabled.agent.md'), type: PromptsType.agent, name: 'enabled', enabled: true, extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, { uri: URI.parse('file:///workspace/.test/agents/disabled.agent.md'), type: PromptsType.agent, name: 'disabled', enabled: false, extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, ], @@ -470,11 +487,11 @@ suite('CustomizationHarnessService', () => { }], testSessionType1, promptsService); store.add(service); { - const agents = (await service.getCustomAgents(testSessionType1, CancellationToken.None)); + const agents = (await service.getCustomAgents(testSessionResource1, CancellationToken.None)); assert.deepStrictEqual(agents.map(agent => [agent.name, agent.enabled]), [['enabled', true], ['disabled', false]]); } { - const agents = (await service.getCustomAgents(testSessionType2, CancellationToken.None)); + const agents = (await service.getCustomAgents(testSessionResource2, CancellationToken.None)); assert.deepStrictEqual(agents.map(agent => [agent.name, agent.enabled]), [['selected', true], ['not-selected', false]]); } }); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts index 29edd802d2cc1..cdf623a02f26a 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts @@ -5,7 +5,7 @@ import { Event } from '../../../../../base/common/event.js'; import { ResourceSet } from '../../../../../base/common/map.js'; -import { observableValue } from '../../../../../base/common/observable.js'; +import { derived, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { IContextMenuService, IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; @@ -15,7 +15,8 @@ import { IWorkspace, IWorkspaceContextService } from '../../../../../platform/wo import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../../contrib/chat/common/aiCustomizationWorkspaceService.js'; import { ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor } from '../../../../contrib/chat/common/customizationHarnessService.js'; import { IAgentPluginService } from '../../../../contrib/chat/common/plugins/agentPluginService.js'; -import { IChatSessionsService, SessionType } from '../../../../contrib/chat/common/chatSessionsService.js'; +import { IChatSessionsService } from '../../../../contrib/chat/common/chatSessionsService.js'; +import { getChatSessionType, LocalChatSessionUri } from '../../../../contrib/chat/common/model/chatUri.js'; import { PromptsType } from '../../../../contrib/chat/common/promptSyntax/promptTypes.js'; import { IPromptsService, AgentInstructionFileType, PromptsStorage, IPromptPath, IAgentInstructionFile } from '../../../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { AICustomizationManagementSection } from '../../../../contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; @@ -119,7 +120,8 @@ function createMockWorkspaceService(): IAICustomizationWorkspaceService { function createMockHarnessService(): ICustomizationHarnessService { const descriptor = createVSCodeHarnessDescriptor([PromptsStorage.extension]); return new class extends mock() { - override readonly activeHarness = observableValue('activeHarness', SessionType.Local); + override readonly activeSessionResource = observableValue('activeSessionResource', LocalChatSessionUri.getNewSessionUri()); + override readonly activeHarness = derived(r => getChatSessionType(this.activeSessionResource.read(r))); override readonly availableHarnesses = observableValue('harnesses', [descriptor]); override getStorageSourceFilter() { return defaultFilter; } override getActiveDescriptor() { return descriptor; } diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index a1ec2496ce54b..bd80ca56c51f0 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -12,7 +12,7 @@ import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { IReference } from '../../../../../base/common/lifecycle.js'; import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; -import { constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { constObservable, derived, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; @@ -38,6 +38,7 @@ import { IAICustomizationWorkspaceService, AICustomizationManagementSection } fr import { ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor, createCliHarnessDescriptor, getCliUserRoots } from '../../../../contrib/chat/common/customizationHarnessService.js'; import { IChatSessionsService, SessionType } from '../../../../contrib/chat/common/chatSessionsService.js'; import { PromptsType } from '../../../../contrib/chat/common/promptSyntax/promptTypes.js'; +import { getChatSessionType, LocalChatSessionUri } from '../../../../contrib/chat/common/model/chatUri.js'; import { IPromptsService, AgentInstructionFileType, PromptsStorage, IAgentSkill, IChatPromptSlashCommand, IAgentInstructionFile } from '../../../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { ParsedPromptFile, PromptFileParser } from '../../../../contrib/chat/common/promptSyntax/promptFileParser.js'; import { IAgentPluginService, IAgentPlugin } from '../../../../contrib/chat/common/plugins/agentPluginService.js'; @@ -241,22 +242,26 @@ function createMockPromptsService(files: IFixtureFile[], agentInstructions: IAge }(); } -function createMockHarnessService(activeHarnessId: string, descriptors: readonly IHarnessDescriptor[]): ICustomizationHarnessService { - const active = observableValue('activeHarness', activeHarnessId); +function createMockHarnessService(sessionResource: URI, descriptors: readonly IHarnessDescriptor[]): ICustomizationHarnessService { + const activeSessionResource = observableValue('activeSessionResource', sessionResource); + const activeHarness = derived(reader => getChatSessionType(activeSessionResource.read(reader))); return new class extends mock() { - override readonly activeHarness = active; + override readonly activeSessionResource = activeSessionResource; + override readonly activeHarness = activeHarness; override readonly availableHarnesses = constObservable(descriptors); override findHarnessById(id: string) { return descriptors.find(h => h.id === id); } override getStorageSourceFilter(type: PromptsType) { - const d = descriptors.find(h => h.id === active.get()) ?? descriptors[0]; + const d = descriptors.find(h => h.id === activeHarness.get()) ?? descriptors[0]; return d.getStorageSourceFilter(type); } override getActiveDescriptor() { - return descriptors.find(h => h.id === active.get()) ?? descriptors[0]; + return descriptors.find(h => h.id === activeHarness.get()) ?? descriptors[0]; + } + override setActiveSession(sessionResource: URI) { + activeSessionResource.set(sessionResource, undefined); } - override setActiveHarness(id: string) { active.set(id, undefined); } override registerExternalHarness() { return { dispose() { } }; } }(); } @@ -419,7 +424,7 @@ const mcpRuntimeServers = [ ]; interface IRenderEditorOptions { - readonly harnessId: string; + readonly sessionResource: URI; readonly isSessionsWindow?: boolean; readonly managementSections?: readonly AICustomizationManagementSection[]; readonly availableHarnesses?: readonly IHarnessDescriptor[]; @@ -524,7 +529,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor const instantiationService = createEditorServices(ctx.disposableStore, { colorTheme: ctx.theme, additionalServices: (reg) => { - const harnessService = createMockHarnessService(options.harnessId, availableHarnesses); + const harnessService = createMockHarnessService(options.sessionResource, availableHarnesses); const agentFeedbackService = createMockAgentFeedbackService(); const codeReviewService = createMockCodeReviewService(); registerWorkbenchServices(reg); @@ -828,7 +833,8 @@ async function renderMcpBrowseMode(ctx: ComponentFixtureContext): Promise } }()); reg.defineInstance(ICustomizationHarnessService, new class extends mock() { - override readonly activeHarness = observableValue('activeHarness', SessionType.Local); + override readonly activeSessionResource = observableValue('activeSessionResource', LocalChatSessionUri.getNewSessionUri()); + override readonly activeHarness = derived(reader => getChatSessionType(this.activeSessionResource.read(reader))); override getActiveDescriptor() { return createVSCodeHarnessDescriptor([PromptsStorage.extension, BUILTIN_STORAGE]); } override registerExternalHarness() { return { dispose() { } }; } }()); @@ -936,7 +942,8 @@ async function renderPluginBrowseMode(ctx: ComponentFixtureContext): Promise() { - override readonly activeHarness = observableValue('activeHarness', SessionType.Local); + override readonly activeSessionResource = observableValue('activeSessionResource', LocalChatSessionUri.getNewSessionUri()); + override readonly activeHarness = derived(reader => getChatSessionType(this.activeSessionResource.read(reader))); override getActiveDescriptor() { return createVSCodeHarnessDescriptor([PromptsStorage.extension, BUILTIN_STORAGE]); } override registerExternalHarness() { return { dispose() { } }; } }()); @@ -1044,7 +1051,8 @@ function renderMcpDisabled(ctx: ComponentFixtureContext, byPolicy: boolean): voi } }()); reg.defineInstance(ICustomizationHarnessService, new class extends mock() { - override readonly activeHarness = observableValue('activeHarness', SessionType.Local); + override readonly activeSessionResource = observableValue('activeSessionResource', LocalChatSessionUri.getNewSessionUri()); + override readonly activeHarness = derived(reader => getChatSessionType(this.activeSessionResource.read(reader))); override getActiveDescriptor() { return createVSCodeHarnessDescriptor([PromptsStorage.extension, BUILTIN_STORAGE]); } override registerExternalHarness() { return { dispose() { } }; } }()); @@ -1069,7 +1077,8 @@ function renderPluginDisabled(ctx: ComponentFixtureContext, byPolicy: boolean): reg.define(IListService, ListService); reg.defineInstance(IConfigurationService, createDisabledConfigService(ChatConfiguration.PluginsEnabled, false, byPolicy)); reg.defineInstance(ICustomizationHarnessService, new class extends mock() { - override readonly activeHarness = observableValue('activeHarness', SessionType.Local); + override readonly activeSessionResource = observableValue('activeSessionResource', LocalChatSessionUri.getNewSessionUri()); + override readonly activeHarness = derived(reader => getChatSessionType(this.activeSessionResource.read(reader))); override getActiveDescriptor() { return createVSCodeHarnessDescriptor([PromptsStorage.extension, BUILTIN_STORAGE]); } override registerExternalHarness() { return { dispose() { } }; } }()); @@ -1185,26 +1194,31 @@ function makeMarketplacePluginItem(name: string, description: string): IAgentPlu // Fixtures // ============================================================================ +const localSessionResource = LocalChatSessionUri.getNewSessionUri(); +const cliSessionResource = URI.parse(`${SessionType.CopilotCLI}:///session1`); + export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { + + // Welcome page — default state with no section selected WelcomePage: defineComponentFixture({ labels: { kind: 'screenshot' }, - render: ctx => renderEditor(ctx, { harnessId: SessionType.Local }), + render: ctx => renderEditor(ctx, { sessionResource: localSessionResource }), }), // Full editor with Local (VS Code) harness — all sections visible, harness dropdown, // Generate buttons, AGENTS.md shortcut, all storage groups LocalHarness: defineComponentFixture({ labels: { kind: 'screenshot' }, - render: ctx => renderEditor(ctx, { harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Agents }), + render: ctx => renderEditor(ctx, { sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Agents }), }), // Full editor with Copilot CLI harness — no prompts section, CLI-specific // root files and instruction filtering under .github/.copilot paths. CliHarness: defineComponentFixture({ labels: { kind: 'screenshot' }, - render: ctx => renderEditor(ctx, { harnessId: SessionType.CopilotCLI, selectedSection: AICustomizationManagementSection.Agents }), + render: ctx => renderEditor(ctx, { sessionResource: cliSessionResource, selectedSection: AICustomizationManagementSection.Agents }), }), // Sessions-window variant of the full editor with workspace override UX @@ -1212,7 +1226,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { Sessions: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harnessId: SessionType.CopilotCLI, + sessionResource: cliSessionResource, isSessionsWindow: true, selectedSection: AICustomizationManagementSection.Agents, availableHarnesses: [ @@ -1234,7 +1248,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { SessionsSkillsTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harnessId: SessionType.CopilotCLI, + sessionResource: cliSessionResource, isSessionsWindow: true, selectedSection: AICustomizationManagementSection.Skills, availableHarnesses: [ @@ -1260,7 +1274,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { McpServersTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harnessId: SessionType.Local, + sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.McpServers, }), }), @@ -1269,7 +1283,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { AgentsTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harnessId: SessionType.Local, + sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Agents, }), }), @@ -1278,7 +1292,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { SkillsTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harnessId: SessionType.Local, + sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Skills, }), }), @@ -1287,7 +1301,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { InstructionsTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harnessId: SessionType.Local, + sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Instructions, }), }), @@ -1296,7 +1310,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { HooksTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harnessId: SessionType.Local, + sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Hooks, }), }), @@ -1305,7 +1319,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { PromptsTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harnessId: SessionType.Local, + sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Prompts, }), }), @@ -1314,7 +1328,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { PluginsTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harnessId: SessionType.Local, + sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Plugins, }), }), @@ -1360,7 +1374,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { PromptsTabScrolled: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harnessId: SessionType.Local, + sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Prompts, scrollToBottom: true, }), @@ -1369,7 +1383,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { McpServersTabScrolled: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harnessId: SessionType.Local, + sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.McpServers, scrollToBottom: true, }), @@ -1378,7 +1392,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { PluginsTabScrolled: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harnessId: SessionType.Local, + sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Plugins, scrollToBottom: true, }), @@ -1388,7 +1402,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { McpServersTabNarrow: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harnessId: SessionType.Local, + sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.McpServers, width: 550, height: 400, @@ -1398,7 +1412,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { AgentsTabNarrow: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harnessId: SessionType.Local, + sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Agents, width: 550, height: 400, @@ -1410,7 +1424,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { AgentsItemPreview: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harnessId: SessionType.Local, + sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Agents, openFirstItem: true, }), @@ -1420,7 +1434,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { AgentsItemRaw: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harnessId: SessionType.Local, + sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Agents, openFirstItem: true, editorDisplayMode: 'raw', @@ -1432,7 +1446,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { BuiltinSkillItemPreview: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harnessId: SessionType.Local, + sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Skills, openFirstItem: true, openItemLabel: 'act-on-feedback', @@ -1443,7 +1457,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { BuiltinSkillItemRaw: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harnessId: SessionType.Local, + sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Skills, openFirstItem: true, openItemLabel: 'act-on-feedback', @@ -1455,7 +1469,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { McpServerDetail: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harnessId: SessionType.Local, + sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.McpServers, openFirstItem: true, }), @@ -1466,7 +1480,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { McpServerDetailNarrow: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harnessId: SessionType.Local, + sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.McpServers, openFirstItem: true, width: 550, @@ -1478,7 +1492,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { PluginDetail: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harnessId: SessionType.Local, + sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Plugins, openFirstItem: true, }), @@ -1487,7 +1501,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { PluginDetailNarrow: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harnessId: SessionType.Local, + sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Plugins, openFirstItem: true, width: 550, diff --git a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts index 3cd229ebe8d22..d452e59897829 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts @@ -157,10 +157,11 @@ declare module 'vscode' { * * The result is cached by the UI until {@link onDidChange} fires. * + * @param sessionResource URI of the chat session whose customizations should be considered. * @param token A cancellation token. * @returns The list of customization items, or `undefined` if unavailable. */ - provideChatSessionCustomizations(token: CancellationToken): ProviderResult; + provideChatSessionCustomizations(sessionResource: Uri, token: CancellationToken): ProviderResult; } // #endregion From 3d0bb6af4c69a1626ad0c2254657927fbfdc636b Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Tue, 19 May 2026 12:47:06 +0100 Subject: [PATCH 08/10] Add new compact codicons for enhanced UI elements (#317291) feat: add new compact codicons for enhanced UI elements Co-authored-by: mrleemurray --- src/vs/base/common/codiconsLibrary.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/vs/base/common/codiconsLibrary.ts b/src/vs/base/common/codiconsLibrary.ts index 49e7c6cb5ae05..1117450a177d7 100644 --- a/src/vs/base/common/codiconsLibrary.ts +++ b/src/vs/base/common/codiconsLibrary.ts @@ -660,4 +660,26 @@ export const codiconsLibrary = { chatImport: register('chat-import', 0xec86), chatExport: register('chat-export', 0xec87), shareWindow: register('share-window', 0xec88), + circleSlashCompact: register('circle-slash-compact', 0xec89), + copilotCompact: register('copilot-compact', 0xec8a), + folderOpenedCompact: register('folder-opened-compact', 0xec8b), + folderCompact: register('folder-compact', 0xec8c), + gearCompact: register('gear-compact', 0xec8d), + gitBranchCompact: register('git-branch-compact', 0xec8e), + libraryCompact: register('library-compact', 0xec8f), + recordKeysCompact: register('record-keys-compact', 0xec90), + remoteCompact: register('remote-compact', 0xec91), + repoForkedCompact: register('repo-forked-compact', 0xec92), + repoCompact: register('repo-compact', 0xec93), + shieldCompact: register('shield-compact', 0xec94), + sparkleCompact: register('sparkle-compact', 0xec95), + symbolColorCompact: register('symbol-color-compact', 0xec96), + windowCompact: register('window-compact', 0xec97), + errorCompact: register('error-compact', 0xec98), + warningCompact: register('warning-compact', 0xec99), + passCompact: register('pass-compact', 0xec9a), + important: register('important', 0xec9b), + importantCompact: register('important-compact', 0xec9c), + rocketCompact: register('rocket-compact', 0xec9d), + unpin: register('unpin', 0xec9e), } as const; From a45bf445cf44c14d709672e9e6694582f65cd09d Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 19 May 2026 21:48:02 +1000 Subject: [PATCH 09/10] feat: implement per-turn changeset recompute logic and associated tests (#317256) * fix: update changeset labels and descriptions to reflect branch changes * Update tests * feat: implement per-turn changeset recompute logic and associated tests * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * updates * Refactor comments in agentService tests to clarify handling of git state in transient sessions * Improve teardown logic in sessionDiffs integration test to handle Windows file locks --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../platform/agentHost/common/changesetUri.ts | 19 +- .../node/agentHostChangesetCoordinator.ts | 66 ++++++- .../node/agentHostChangesetService.ts | 121 ++++++++++-- .../platform/agentHost/node/agentService.ts | 51 ++++- .../node/agentHostChangesetService.test.ts | 174 ++++++++++++++++-- .../agentHost/test/node/agentService.test.ts | 154 ++++++++++++---- .../test/node/agentSideEffects.test.ts | 2 + .../protocol/sessionDiffs.integrationTest.ts | 12 +- .../test/node/protocolServerHandler.test.ts | 4 +- 9 files changed, 521 insertions(+), 82 deletions(-) diff --git a/src/vs/platform/agentHost/common/changesetUri.ts b/src/vs/platform/agentHost/common/changesetUri.ts index 7a6476496e185..d42c59320dd63 100644 --- a/src/vs/platform/agentHost/common/changesetUri.ts +++ b/src/vs/platform/agentHost/common/changesetUri.ts @@ -38,11 +38,14 @@ const TURN_CHANGESET_PREFIX = 'turn/'; const TURN_TEMPLATE_VARIABLE = '{turnId}'; /** Localized human-readable label for the session-wide changeset entry. */ -export const sessionChangesetLabel = (): string => localize('sessionChangeset.label', "Session Changes"); +export const sessionChangesetLabel = (): string => localize('branchChangeset.label', "Branch Changes"); /** Localized human-readable label for the uncommitted-changes changeset entry. */ export const uncommittedChangesetLabel = (): string => localize('uncommittedChangeset.label', "Uncommitted Changes"); +/** Localized human-readable description for the uncommitted-changes changeset entry. */ +export const uncommittedChangesetDescription = (): string => localize('uncommittedChangeset.description', "Show uncommitted changes in this session"); + /** Localized human-readable label for the per-turn changeset template entry. */ export const thisTurnChangesetLabel = (): string => localize('thisTurnChangeset.label', "This Turn"); @@ -160,18 +163,20 @@ export function parseTurnChangesetUri(uri: URI): { sessionUri: URI; turnId: stri /** * Builds the default ordered `summary.changesets` catalogue for a - * session (`Uncommitted Changes`, `Session Changes`, `This Turn`) with + * session (`Branch Changes`, `Uncommitted Changes`, `This Turn`) with * label + uriTemplate only. Aggregate counts are filled in later by the - * diff producer as compute passes complete; clients MUST treat - * `summary.changesets[0]` as the default rather than singling out an id. + * diff producer as compute passes complete. * - * Catalogue shape is immutable for the session's lifetime — only the - * per-entry stats update over time. + * The first two entries (`Branch Changes`, `Uncommitted Changes`) are + * git-only; `AgentService._attachGitState` strips them asynchronously + * for sessions whose working directory is not a git repo (or absent). + * The backing per-changeset states are still registered for every + * session — only the catalogue advertisements are stripped. */ export function buildDefaultChangesetCatalogue(sessionUri: URI): ChangesetSummary[] { return [ - { label: uncommittedChangesetLabel(), uriTemplate: buildUncommittedChangesetUri(sessionUri) }, { label: sessionChangesetLabel(), uriTemplate: buildSessionChangesetUri(sessionUri) }, + { label: uncommittedChangesetLabel(), uriTemplate: buildUncommittedChangesetUri(sessionUri), description: uncommittedChangesetDescription() }, { label: thisTurnChangesetLabel(), uriTemplate: buildTurnChangesetUriTemplate(sessionUri) }, ]; } diff --git a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts index 01eece738bb2b..f2532c3433368 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts @@ -70,12 +70,32 @@ export class ChangesetSessionCoordinator extends Disposable { */ private readonly _pendingUncommittedRefreshes = new Set(); + /** + * Per-session set of turn ids that have at least one live subscriber to + * `/changeset/turn/`. Drives the per-turn recompute + * gating: the changeset service only schedules a per-turn recompute when + * this set says someone is watching the turn URI (per-turn URIs have no + * catalogue chip aggregates, so recomputing for an unobserved turn is + * pure waste). + */ + private readonly _subscribedTurns = new Map>(); + constructor( private readonly _stateManager: AgentHostStateManager, private readonly _changesets: IAgentHostChangesetService, private readonly _configurationService: IAgentConfigurationService, ) { super(); + this._changesets.setTurnSubscriberProbe((session, turnId) => this.hasTurnSubscribers(session, turnId)); + } + + /** + * Returns `true` when at least one client is subscribed to + * `/changeset/turn/`. Consulted by the changeset + * service via the probe installed in the constructor. + */ + hasTurnSubscribers(session: string, turnId: string): boolean { + return this._subscribedTurns.get(session)?.has(turnId) ?? false; } // ---- Lifecycle hooks ---------------------------------------------------- @@ -131,6 +151,7 @@ export class ChangesetSessionCoordinator extends Disposable { */ onSessionDisposed(sessionStr: string): void { this._pendingUncommittedRefreshes.delete(sessionStr); + this._subscribedTurns.delete(sessionStr); } // ---- Subscription hooks ------------------------------------------------- @@ -145,9 +166,42 @@ export class ChangesetSessionCoordinator extends Disposable { * `addSubscriber`, so this single hook covers both paths. */ onFirstSubscriber(resource: URI): void { - const parsed = parseChangesetUri(resource.toString()); + const resourceStr = resource.toString(); + const parsed = parseChangesetUri(resourceStr); if (parsed?.kind === ChangesetKind.Uncommitted) { this._triggerUncommittedRefresh(parsed.sessionUri); + return; + } + if (parsed?.kind === ChangesetKind.Session) { + // Session-changeset compute uses git when a working dir is + // available and falls back to the SDK edit-tracker otherwise, + // so it doesn't need the same deferral as uncommitted. + this._changesets.refreshSessionChangeset(parsed.sessionUri); + return; + } + if (parsed?.kind === ChangesetKind.Turn && parsed.turnId !== undefined) { + // Track the new subscriber so the service's per-turn recompute + // gating starts including this turn. The initial snapshot is + // already produced by `tryHandleSubscribe → computeTurnChangeset`; + // subsequent deltas flow from `onToolCallEditsApplied` / + // `onTurnComplete` once we've added this turn id here. + let set = this._subscribedTurns.get(parsed.sessionUri); + if (!set) { + set = new Set(); + this._subscribedTurns.set(parsed.sessionUri, set); + } + set.add(parsed.turnId); + return; + } + if (!parsed && this._stateManager.getSessionState(resourceStr)) { + // Plain session-URI subscription (Agents Window list / detail + // observing the session). Refresh both static changesets so + // the catalogue chip doesn't show a stale value just because + // no turn has run since process start, no one ever subscribed + // to the changeset URIs directly, and the user has been + // editing files manually in the working tree. + this._triggerUncommittedRefresh(resourceStr); + this._changesets.refreshSessionChangeset(resourceStr); } } @@ -160,6 +214,16 @@ export class ChangesetSessionCoordinator extends Disposable { const parsed = parseChangesetUri(resource.toString()); if (parsed?.kind === ChangesetKind.Uncommitted) { this._pendingUncommittedRefreshes.delete(parsed.sessionUri); + return; + } + if (parsed?.kind === ChangesetKind.Turn && parsed.turnId !== undefined) { + const set = this._subscribedTurns.get(parsed.sessionUri); + if (set) { + set.delete(parsed.turnId); + if (set.size === 0) { + this._subscribedTurns.delete(parsed.sessionUri); + } + } } } diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index 440b01f8b04a3..573bbb62a30cb 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -17,6 +17,7 @@ import { sessionChangesetLabel, thisTurnChangesetLabel, uncommittedChangesetLabel, + uncommittedChangesetDescription, } from '../common/changesetUri.js'; import { IDiffComputeService } from '../common/diffComputeService.js'; import { ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js'; @@ -65,11 +66,13 @@ function persistKeyFor(kind: StaticChangesetKind): string { /** * Builds a single static {@link ChangesetSummary} catalogue entry from a * persisted (or live-state-derived) file list. Returns the bare entry - * (no counts) when `diffs` is undefined. + * (no counts) when `diffs` is undefined. Optional `description` is + * threaded through when provided. */ -function buildStaticCatalogueEntry(label: string, uri: string, diffs: readonly ISessionFileDiff[] | undefined): ChangesetSummary { +function buildStaticCatalogueEntry(label: string, uri: string, diffs: readonly ISessionFileDiff[] | undefined, description?: string): ChangesetSummary { + const base: ChangesetSummary = description ? { label, uriTemplate: uri, description } : { label, uriTemplate: uri }; if (!diffs) { - return { label, uriTemplate: uri }; + return base; } let additions = 0; let deletions = 0; @@ -77,7 +80,7 @@ function buildStaticCatalogueEntry(label: string, uri: string, diffs: readonly I additions += d.diff?.added ?? 0; deletions += d.diff?.removed ?? 0; } - return { label, uriTemplate: uri, additions, deletions, files: diffs.length }; + return { ...base, additions, deletions, files: diffs.length }; } function defaultCatalogueWithCounts( @@ -86,15 +89,15 @@ function defaultCatalogueWithCounts( sessionDiffs: readonly ISessionFileDiff[] | undefined, ): ChangesetSummary[] { return [ - buildStaticCatalogueEntry(uncommittedChangesetLabel(), buildUncommittedChangesetUri(sessionUri), uncommittedDiffs), buildStaticCatalogueEntry(sessionChangesetLabel(), buildSessionChangesetUri(sessionUri), sessionDiffs), + buildStaticCatalogueEntry(uncommittedChangesetLabel(), buildUncommittedChangesetUri(sessionUri), uncommittedDiffs, uncommittedChangesetDescription()), { label: thisTurnChangesetLabel(), uriTemplate: buildTurnChangesetUriTemplate(sessionUri) }, ]; } /** - * Build the default ordered changeset catalogue (`Uncommitted Changes`, - * `Session Changes`, `This Turn`) seeded from the live {@link ChangesetState} + * Build the default ordered changeset catalogue (`Branch Changes`, + * `Uncommitted Changes`, `This Turn`) seeded from the live {@link ChangesetState} * for an unopened session that has no live `SessionState` but already has * ready changeset states (e.g. from a prior `restoreStaticChangeset` call). * @@ -103,8 +106,11 @@ function defaultCatalogueWithCounts( * have no usable counts yet — preserving the long-standing contract that * unopened sessions without persisted or live data advertise no catalogue. * - * Clients MUST treat `summary.changesets[0]` as the default — `Uncommitted - * Changes` is first by virtue of catalogue ordering, not by hardcoded id. + * The two static entries (`Branch Changes`, `Uncommitted Changes`) are + * git-only — `AgentService._attachGitState` strips them from the live + * `summary.changesets` for non-git working directories. The synthesised + * catalogue here mirrors the live-state shape so list overlays stay + * consistent with the per-session catalogue clients subscribe to. */ export function buildCatalogueFromLiveState( sessionUri: string, @@ -243,6 +249,15 @@ export interface IAgentHostChangesetService { */ refreshUncommittedChangeset(session: ProtocolURI): void; + /** + * Lazy refresh of the session (branch) changeset, kicked off when a + * client first subscribes to `/changeset/session` or the + * session URI itself (e.g. Agents Window observing the session). Mirrors + * {@link refreshUncommittedChangeset} so the catalogue chip stays fresh + * across session opens even when no turn has run since process start. + */ + refreshSessionChangeset(session: ProtocolURI): void; + /** * Computes and publishes the per-turn changeset for `turnId` on `session`. * Per-turn changesets are not persisted. @@ -268,6 +283,14 @@ export interface IAgentHostChangesetService { * `changedTurnId`, no incremental reuse). */ onSessionTruncated(session: ProtocolURI): void; + + /** + * Installs a predicate the service consults before scheduling a + * per-turn changeset recompute. Owned by {@link ChangesetSessionCoordinator}, + * which tracks per-turn subscribers via `onFirstSubscriber` / + * `onLastSubscriber`. Called exactly once at coordinator construction. + */ + setTurnSubscriberProbe(probe: (session: ProtocolURI, turnId: string) => boolean): void; } export class AgentHostChangesetService extends Disposable implements IAgentHostChangesetService { @@ -279,8 +302,24 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC private readonly _diffComputationSequencer = new SequencerByKey(); /** Per-session debounce timers for mid-turn diff computation. */ private readonly _debouncedDiffTimers = this._register(new DisposableMap()); + /** Per-`(session, turnId)` debounce timers for mid-turn per-turn changeset recomputation. */ + private readonly _perTurnDebouncedDiffTimers = this._register(new DisposableMap()); private static readonly _DIFF_DEBOUNCE_MS = 5000; + /** + * Subscriber probe set by {@link ChangesetSessionCoordinator}. Returns + * `true` when at least one client is subscribed to + * `/changeset/turn/`. Per-turn URIs carry no catalogue + * chip aggregates, so recomputing for an unobserved turn is pure waste + * — the service consults this probe in {@link onToolCallEditsApplied} + * and {@link onTurnComplete} before scheduling a per-turn recompute. + * + * Defaults to `() => false` so unwired test instances don't accidentally + * fire per-turn computes; the coordinator overrides this in its + * constructor. + */ + private _hasTurnSubscribers: (session: ProtocolURI, turnId: string) => boolean = () => false; + constructor( private readonly _stateManager: AgentHostStateManager, @ILogService private readonly _logService: ILogService, @@ -291,6 +330,10 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC this._diffComputeService = this._register(new NodeWorkerDiffComputeService(this._logService)); } + setTurnSubscriberProbe(probe: (session: ProtocolURI, turnId: string) => boolean): void { + this._hasTurnSubscribers = probe; + } + registerStaticChangesets(session: ProtocolURI): void { this._stateManager.registerChangeset(buildUncommittedChangesetUri(session)); this._stateManager.registerChangeset(buildSessionChangesetUri(session)); @@ -334,6 +377,10 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC this._scheduleStaticRecompute(session, 'uncommitted'); } + refreshSessionChangeset(session: ProtocolURI): void { + this._scheduleStaticRecompute(session, 'session'); + } + async computeTurnChangeset(session: ProtocolURI, turnId: string): Promise { const turnUri = this._stateManager.registerChangeset(buildTurnChangesetUri(session, turnId)); let ref: ReturnType; @@ -370,14 +417,28 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC onToolCallEditsApplied(session: ProtocolURI, turnId: string): void { this._scheduleDebouncedDiffComputation(session, turnId); + // Per-turn URIs have no catalogue chip aggregates, so skip the + // recompute entirely when no client is observing this turn. The + // next subscriber will get a fresh snapshot from + // `tryHandleSubscribe → computeTurnChangeset`. + if (this._hasTurnSubscribers(session, turnId)) { + this._scheduleDebouncedTurnDiffComputation(session, turnId); + } } onTurnComplete(session: ProtocolURI, turnId: string | undefined): void { - // Ordering matters: cancel any pending mid-turn debounce first so - // the final turn-complete compute supersedes it; then schedule the - // session-wide recompute with the changed turn id (incremental - // reuse anchor); then the uncommitted recompute with no turn id. + // Ordering matters for cancellation: cancel any pending mid-turn + // debounces first so the final turn-complete computes supersede + // them. After that, schedule the final recomputes for the turn + // (when observed), the session-wide changeset with the changed + // turn id, and the uncommitted changeset with no turn id. this._cancelDebouncedDiffComputation(session); + if (turnId !== undefined) { + this._cancelDebouncedTurnDiffComputation(session, turnId); + if (this._hasTurnSubscribers(session, turnId)) { + this._scheduleTurnRecompute(session, turnId); + } + } this._scheduleStaticRecompute(session, 'session', turnId); this._scheduleStaticRecompute(session, 'uncommitted'); } @@ -410,6 +471,40 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC this._debouncedDiffTimers.deleteAndDispose(session); } + /** + * Schedules a debounced per-turn changeset recomputation. Mirrors + * {@link _scheduleDebouncedDiffComputation} but uses a per- + * `(session, turnId)` map key so a long-running per-turn compute + * doesn't block the static session recompute path (and vice versa). + */ + private _scheduleDebouncedTurnDiffComputation(session: ProtocolURI, turnId: string): void { + const key = `${session}\u0000${turnId}`; + this._perTurnDebouncedDiffTimers.set(key, disposableTimeout(() => { + this._perTurnDebouncedDiffTimers.deleteAndDispose(key); + this._scheduleTurnRecompute(session, turnId); + }, AgentHostChangesetService._DIFF_DEBOUNCE_MS)); + } + + /** + * Cancels any pending debounced per-turn diff computation for a + * `(session, turnId)`. Called at turn end before the final + * (non-debounced) per-turn computation. + */ + private _cancelDebouncedTurnDiffComputation(session: ProtocolURI, turnId: string): void { + this._perTurnDebouncedDiffTimers.deleteAndDispose(`${session}\u0000${turnId}`); + } + + /** + * Queues a per-turn recompute on a per-`(session, turnId)` sequencer + * key so back-to-back recomputes for the same turn serialise, but + * recomputes for different turns (or for the static `session` / + * `uncommitted` slots) run independently. Fire-and-forget — failures + * are logged inside `computeTurnChangeset` and do not fail the turn. + */ + private _scheduleTurnRecompute(session: ProtocolURI, turnId: string): void { + this._diffComputationSequencer.queue(`${session}\u0000turn\u0000${turnId}`, () => this.computeTurnChangeset(session, turnId).then(() => undefined)); + } + /** * Schedules a static changeset (`uncommitted` or `session`) recompute, * serialised per-session so back-to-back triggers don't race against diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 53d94168b1b92..d3382f18d3dc3 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -21,7 +21,7 @@ import { ServiceCollection } from '../../instantiation/common/serviceCollection. import { ILogService } from '../../log/common/log.js'; import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentMaterializeSessionEvent, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../common/agentService.js'; import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; -import { buildDefaultChangesetCatalogue } from '../common/changesetUri.js'; +import { buildDefaultChangesetCatalogue, buildSessionChangesetUri, buildUncommittedChangesetUri } from '../common/changesetUri.js'; import { ActionType, ActionEnvelope, INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; import type { CompletionsParams, CompletionsResult, 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'; @@ -452,10 +452,12 @@ export class AgentService extends Disposable implements IAgentService { // run before `SessionReady` is dispatched. Any future change must // keep both halves at create time so client subscriptions resolve // to a `status: computing` snapshot rather than a 404 even on - // provisional sessions, and so the catalogue chip renders the three - // default entries (`Uncommitted Changes`, `Session Changes`, - // `This Turn`) immediately. Pinned by item-2 regression tests in - // `agentService.test.ts`. + // provisional sessions, and so the catalogue chip renders the + // default entries (`Branch Changes`, `Uncommitted Changes`, + // `This Turn`) immediately. The first two are git-only: a later + // `_attachGitState` strips them once the git probe confirms the + // resolved working directory is not a git repo. Pinned by item-2 + // regression tests in `agentService.test.ts`. this._changesetCoordinator.onSessionCreated(session.toString()); if (!created.provisional) { @@ -541,17 +543,30 @@ export class AgentService extends Disposable implements IAgentService { * working directory (if any) and merges it into `state._meta.git` via * the state manager. Failures are logged; sessions simply remain without * git state. + * + * Also gates the two git-only default catalogue entries + * (`Branch Changes`, `Uncommitted Changes`): when the working + * directory is resolved AND the git probe confirms it is not a git + * repo, those entries are stripped from `summary.changesets`, leaving + * only `This Turn`. An absent working directory is treated as + * transient (provisional / pre-materialize / pre-restore) — we do NOT + * strip in that case because there is no path that re-adds the + * entries when a subsequent `onSessionMaterialized` / restore call + * resolves the working directory and the probe succeeds. The + * entries' counts remain unset until a real compute lands, so chip + * rendering naturally skips them in the meantime. */ private _attachGitState(session: URI, workingDirectory: URI | undefined): void { if (!workingDirectory) { return; } + const sessionKey = session.toString(); this._gitService.getSessionGitState(workingDirectory).then( gitState => { if (!gitState) { + this._stripGitOnlyChangesetEntries(sessionKey); return; } - const sessionKey = session.toString(); const current = this._stateManager.getSessionState(sessionKey)?._meta; // Skip the action if the computed git state hasn't changed; this is // called after every turn, so deduping avoids needless action churn. @@ -567,6 +582,30 @@ export class AgentService extends Disposable implements IAgentService { ); } + /** + * Drops the `Branch Changes` and `Uncommitted Changes` entries from + * the session's catalogue. Called only when the git probe has + * definitively determined the working directory is not a git repo. + * An absent / unresolved working directory is treated as transient + * and does NOT trigger a strip — see {@link _attachGitState}. + * Backing per-changeset states (registered unconditionally) are left + * in place — only the catalogue advertisements are stripped. + */ + private _stripGitOnlyChangesetEntries(sessionKey: string): void { + const state = this._stateManager.getSessionState(sessionKey); + const current = state?.summary.changesets; + if (!current || current.length === 0) { + return; + } + const branchUri = buildSessionChangesetUri(sessionKey); + const uncommittedUri = buildUncommittedChangesetUri(sessionKey); + const filtered = current.filter(c => c.uriTemplate !== branchUri && c.uriTemplate !== uncommittedUri); + if (filtered.length === current.length) { + return; + } + this._stateManager.setSessionChangesets(sessionKey, filtered); + } + private _persistConfigValues(session: URI, values: Record): void { let ref; try { diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index bb11f4bd1e9d0..4d10d3d30c53b 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -8,6 +8,7 @@ import { timeout } from '../../../../base/common/async.js'; import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { NullLogService } from '../../../log/common/log.js'; import { AgentSession } from '../../common/agentService.js'; import { buildDefaultChangesetCatalogue } from '../../common/changesetUri.js'; @@ -64,8 +65,8 @@ suite('AgentHostChangesetService', () => { // Catalogue is seeded by setupSession (mirrors what `_buildInitialSummary` // does in production) — sanity check before exercising registration. assert.deepStrictEqual(stateManager.getSessionState(sessionStr)?.summary.changesets, [ - { label: 'Uncommitted Changes', uriTemplate: `${sessionStr}/changeset/uncommitted` }, - { label: 'Session Changes', uriTemplate: `${sessionStr}/changeset/session` }, + { label: 'Branch Changes', uriTemplate: `${sessionStr}/changeset/session` }, + { label: 'Uncommitted Changes', uriTemplate: `${sessionStr}/changeset/uncommitted`, description: 'Show uncommitted changes in this session' }, { label: 'This Turn', uriTemplate: `${sessionStr}/changeset/turn/{turnId}` }, ]); @@ -82,8 +83,8 @@ suite('AgentHostChangesetService', () => { // Registration must not mutate the seeded catalogue. assert.deepStrictEqual(stateManager.getSessionState(sessionStr)?.summary.changesets, [ - { label: 'Uncommitted Changes', uriTemplate: `${sessionStr}/changeset/uncommitted` }, - { label: 'Session Changes', uriTemplate: `${sessionStr}/changeset/session` }, + { label: 'Branch Changes', uriTemplate: `${sessionStr}/changeset/session` }, + { label: 'Uncommitted Changes', uriTemplate: `${sessionStr}/changeset/uncommitted`, description: 'Show uncommitted changes in this session' }, { label: 'This Turn', uriTemplate: `${sessionStr}/changeset/turn/{turnId}` }, ]); }); @@ -127,16 +128,17 @@ suite('AgentHostChangesetService', () => { const catalogue = stateManager.getSessionState(sessionStr)?.summary.changesets; assert.deepStrictEqual(catalogue, [ { - label: 'Uncommitted Changes', - uriTemplate: `${sessionStr}/changeset/uncommitted`, - }, - { - label: 'Session Changes', + label: 'Branch Changes', uriTemplate: changesetUri, additions: 6, deletions: 2, files: 2, }, + { + label: 'Uncommitted Changes', + uriTemplate: `${sessionStr}/changeset/uncommitted`, + description: 'Show uncommitted changes in this session', + }, { label: 'This Turn', uriTemplate: `${sessionStr}/changeset/turn/{turnId}`, @@ -182,16 +184,17 @@ suite('AgentHostChangesetService', () => { ], catalogue: [ { - label: 'Uncommitted Changes', - uriTemplate: `${sessionStr}/changeset/uncommitted`, - }, - { - label: 'Session Changes', + label: 'Branch Changes', uriTemplate: changesetUri, additions: 4, deletions: 1, files: 2, }, + { + label: 'Uncommitted Changes', + uriTemplate: `${sessionStr}/changeset/uncommitted`, + description: 'Show uncommitted changes in this session', + }, { label: 'This Turn', uriTemplate: `${sessionStr}/changeset/turn/{turnId}`, @@ -527,7 +530,7 @@ suite('AgentHostChangesetService', () => { const catalogue = stateManager.getSessionState(sessionStr)?.summary.changesets; const sessionEntry = catalogue?.find(c => c.uriTemplate === `${sessionStr}/changeset/session`); assert.deepStrictEqual(sessionEntry, { - label: 'Session Changes', + label: 'Branch Changes', uriTemplate: `${sessionStr}/changeset/session`, additions: 3, deletions: 0, @@ -535,4 +538,145 @@ suite('AgentHostChangesetService', () => { }, 'catalogue counts must reflect restored files'); }); }); + + suite('per-turn live streaming', () => { + + // Test rig: a subclass that counts `computeTurnChangeset` invocations + // so we can assert gating wiring without needing real session DB + // content for `computeTurnDiffs` to chew on. The base class behaviour + // is preserved (super-call is awaited), so any per-file dispatch the + // production path would emit still flows through normally. + class CountingChangesetService extends AgentHostChangesetService { + readonly turnComputeCalls: { session: string; turnId: string }[] = []; + override async computeTurnChangeset(session: string, turnId: string): Promise { + this.turnComputeCalls.push({ session, turnId }); + return super.computeTurnChangeset(session, turnId); + } + } + + function makeService(): CountingChangesetService { + return disposables.add(new CountingChangesetService( + stateManager, + new NullLogService(), + createNullSessionDataService(), + createNoopGitService(), + )); + } + + test('onTurnComplete schedules a per-turn recompute when the probe says someone is subscribed', async () => { + setupSession(); + const svc = makeService(); + svc.setTurnSubscriberProbe(() => true); + + svc.onTurnComplete(sessionUri.toString(), 'turn-1'); + + // Sequencer drains async; wait briefly for the per-turn call. + for (let i = 0; i < 50 && svc.turnComputeCalls.length === 0; i++) { + await timeout(2); + } + assert.deepStrictEqual( + svc.turnComputeCalls, + [{ session: sessionUri.toString(), turnId: 'turn-1' }], + 'expected exactly one per-turn compute for the completed turn', + ); + }); + + test('onTurnComplete does NOT schedule a per-turn recompute when the probe says nobody is subscribed', async () => { + setupSession(); + const svc = makeService(); + svc.setTurnSubscriberProbe(() => false); + + svc.onTurnComplete(sessionUri.toString(), 'turn-1'); + + // Give the static computes a chance to drain — the per-turn + // call must remain absent throughout. + await timeout(20); + assert.deepStrictEqual(svc.turnComputeCalls, [], 'no per-turn compute when nothing observes the turn URI'); + }); + + test('onToolCallEditsApplied fires the per-turn debounce only when subscribers exist; cancelled by onTurnComplete', () => { + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + setupSession(); + const svc = makeService(); + svc.setTurnSubscriberProbe(() => true); + + // 1) edits with subscriber -> after debounce, exactly one per-turn compute fires. + svc.onToolCallEditsApplied(sessionUri.toString(), 'turn-1'); + await timeout(6_000); // debounce is 5s + assert.strictEqual(svc.turnComputeCalls.length, 1, 'debounce should fire one per-turn compute'); + + // 2) another edit batch + onTurnComplete before the debounce + // elapses -> the debounce is cancelled and the final compute + // is scheduled directly by onTurnComplete (one additional call). + svc.onToolCallEditsApplied(sessionUri.toString(), 'turn-1'); + await timeout(1_000); + svc.onTurnComplete(sessionUri.toString(), 'turn-1'); + await timeout(10); + assert.strictEqual(svc.turnComputeCalls.length, 2, 'onTurnComplete cancels pending debounce and runs exactly one final compute'); + + // 3) flipping the probe off mid-stream silences future + // per-turn computes even if more edits arrive. + svc.setTurnSubscriberProbe(() => false); + svc.onToolCallEditsApplied(sessionUri.toString(), 'turn-1'); + await timeout(6_000); + assert.strictEqual(svc.turnComputeCalls.length, 2, 'unsubscribed turn must not get any further per-turn computes'); + }); + }); + + test('per-turn URI streams incremental ChangesetFileSet / ChangesetFileRemoved as the same turn is recomputed', async () => { + // End-to-end variant exercising the real `computeTurnDiffs` path + // — produces actual diff payloads from session-DB messages so + // `_publishChangesetDiffs` emits real per-file actions on each + // recompute pass. + const sessionDb = new SessionDatabase(':memory:'); + disposables.add(toDisposable(() => sessionDb.close())); + const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const svc = disposables.add(new AgentHostChangesetService( + localStateManager, + new NullLogService(), + createSessionDataService(sessionDb), + createNoopGitService(), + )); + svc.setTurnSubscriberProbe(() => true); + + localStateManager.createSession({ + resource: sessionUri.toString(), + provider: 'mock', + title: 'Test', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + workingDirectory: 'file:///wd', + }); + + const envelopes: ActionEnvelope[] = []; + disposables.add(localStateManager.onDidEmitEnvelope(e => envelopes.push(e))); + const turnUri = `${sessionUri.toString()}/changeset/turn/turn-1`; + + // First compute pass — no edits yet, so just establishes the + // per-turn state at status: ready with an empty file list. + await svc.computeTurnChangeset(sessionUri.toString(), 'turn-1'); + const statusReady = envelopes + .map(e => e.action) + .find(a => a.type === ActionType.ChangesetStatusChanged && a.changeset === turnUri); + assert.ok(statusReady, 'first per-turn compute must transition the URI to ready'); + + // Subsequent recomputes are observable via `_publishChangesetDiffs` + // even with empty diffs — the delta diffing is what matters here. + // Smoke-check that calling `onTurnComplete` triggers another + // `computeTurnChangeset` invocation through the sequencer. + envelopes.length = 0; + svc.onTurnComplete(sessionUri.toString(), 'turn-1'); + for (let i = 0; i < 100 && !envelopes.some(e => e.action.type === ActionType.ChangesetStatusChanged && e.action.changeset === `${sessionUri.toString()}/changeset/session`); i++) { + await timeout(2); + } + // Per-turn recompute was scheduled — at minimum its presence is + // proven by the static-session recompute also having run (both + // share the same `onTurnComplete` dispatch path). + assert.ok( + envelopes.some(e => e.action.type === ActionType.ChangesetStatusChanged), + 'onTurnComplete must drive at least one downstream changeset status transition', + ); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 847cc0e481826..8f169b3302ce4 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -553,16 +553,17 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(sessions.length, 1); assert.deepStrictEqual(sessions[0].changesets, [ { - label: 'Uncommitted Changes', - uriTemplate: `${sessionUri.toString()}/changeset/uncommitted`, - }, - { - label: 'Session Changes', + label: 'Branch Changes', uriTemplate: `${sessionUri.toString()}/changeset/session`, additions: 8, deletions: 2, files: 2, }, + { + label: 'Uncommitted Changes', + uriTemplate: `${sessionUri.toString()}/changeset/uncommitted`, + description: 'Show uncommitted changes in this session', + }, { label: 'This Turn', uriTemplate: `${sessionUri.toString()}/changeset/turn/{turnId}`, @@ -692,16 +693,17 @@ suite('AgentService (node dispatcher)', () => { const sessions = await svc.listSessions(); assert.deepStrictEqual(sessions[0].changesets, [ { - label: 'Uncommitted Changes', - uriTemplate: `${sessionUri.toString()}/changeset/uncommitted`, - }, - { - label: 'Session Changes', + label: 'Branch Changes', uriTemplate: changesetUri, additions: 1, deletions: 0, files: 1, }, + { + label: 'Uncommitted Changes', + uriTemplate: `${sessionUri.toString()}/changeset/uncommitted`, + description: 'Show uncommitted changes in this session', + }, { label: 'This Turn', uriTemplate: `${sessionUri.toString()}/changeset/turn/{turnId}`, @@ -795,16 +797,17 @@ suite('AgentService (node dispatcher)', () => { const sessions = await svc.listSessions(); assert.deepStrictEqual(sessions[0].changesets, [ { - label: 'Uncommitted Changes', - uriTemplate: `${sessionUri.toString()}/changeset/uncommitted`, - }, - { - label: 'Session Changes', + label: 'Branch Changes', uriTemplate: `${sessionUri.toString()}/changeset/session`, additions: 7, deletions: 1, files: 1, }, + { + label: 'Uncommitted Changes', + uriTemplate: `${sessionUri.toString()}/changeset/uncommitted`, + description: 'Show uncommitted changes in this session', + }, { label: 'This Turn', uriTemplate: `${sessionUri.toString()}/changeset/turn/{turnId}`, @@ -916,6 +919,66 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(localService.stateManager.getSessionState(session.toString())?._meta, undefined); }); + test('createSession strips git-only catalogue entries for non-git working directory', async () => { + const workingDirectory = URI.file('/workspace/not-a-repo'); + const gitService = createNoopGitService(); + // Probe runs but reports "not a git repo". + gitService.getSessionGitState = async () => undefined; + + const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const agent = new MockAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + agent.resolvedWorkingDirectory = workingDirectory; + agent.sessionMetadataOverrides = { workingDirectory }; + localService.registerProvider(agent); + + const session = await localService.createSession({ provider: 'copilot' }); + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } + + const state = localService.stateManager.getSessionState(session.toString()); + assert.ok(state); + assert.deepStrictEqual(state!.summary.changesets, [ + { label: 'This Turn', uriTemplate: `${session.toString()}/changeset/turn/{turnId}` }, + ]); + }); + + test('createSession keeps git-only catalogue entries for a git working directory', async () => { + const workingDirectory = URI.file('/workspace/repo'); + const gitState = { + hasGitHubRemote: false, + branchName: 'main', + baseBranchName: 'main', + upstreamBranchName: undefined, + incomingChanges: 0, + outgoingChanges: 0, + uncommittedChanges: 0, + }; + const gitService = createNoopGitService(); + gitService.getSessionGitState = async () => gitState; + + const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const agent = new MockAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + agent.resolvedWorkingDirectory = workingDirectory; + agent.sessionMetadataOverrides = { workingDirectory }; + localService.registerProvider(agent); + + const session = await localService.createSession({ provider: 'copilot' }); + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } + + const state = localService.stateManager.getSessionState(session.toString()); + assert.ok(state); + assert.deepStrictEqual(state!.summary.changesets, [ + { label: 'Branch Changes', uriTemplate: `${session.toString()}/changeset/session` }, + { label: 'Uncommitted Changes', uriTemplate: `${session.toString()}/changeset/uncommitted`, description: 'Show uncommitted changes in this session' }, + { label: 'This Turn', uriTemplate: `${session.toString()}/changeset/turn/{turnId}` }, + ]); + }); + test('subscribe lazily attaches git state when an existing session has no _meta.git', async () => { // Regression test: previously AgentService was constructed without // a git service, so _attachGitState always bailed and `_meta.git` @@ -1634,15 +1697,20 @@ suite('AgentService (node dispatcher)', () => { localService.unsubscribe(uncommittedUri, 'client-1'); }); - test('addSubscriber for non-uncommitted resources does NOT trigger a refresh', async () => { + test('addSubscriber for the session URI or session-changeset URI triggers a static refresh', async () => { + // The Agents Window subscribes to the session URI (list / + // detail) rather than to either of the static changeset URIs + // directly, so the chip would never refresh on session open + // without this trigger. Subscribing to the session-changeset + // URI from any other client must also fire its own refresh. const workingDirectory = URI.from({ scheme: Schemas.inMemory, path: '/wd-refresh-2' }); copilotAgent.resolvedWorkingDirectory = workingDirectory; copilotAgent.sessionMetadataOverrides = { workingDirectory }; - const computeCalls: { baseBranch: string | undefined }[] = []; + const computeCalls: { wd: string; baseBranch: string | undefined }[] = []; const gitService = createNoopGitService(); - gitService.computeSessionFileDiffs = async (_wd: URI, opts: { sessionUri: string; baseBranch?: string }) => { - computeCalls.push({ baseBranch: opts.baseBranch }); + gitService.computeSessionFileDiffs = async (wd: URI, opts: { sessionUri: string; baseBranch?: string }) => { + computeCalls.push({ wd: wd.toString(), baseBranch: opts.baseBranch }); return undefined; }; @@ -1653,16 +1721,16 @@ suite('AgentService (node dispatcher)', () => { const sessionChangesetUri = URI.parse(buildSessionChangesetUri(sessionResource.toString())); localService.addSubscriber(sessionChangesetUri, 'client-1'); - localService.addSubscriber(sessionResource, 'client-1'); + localService.addSubscriber(sessionResource, 'client-2'); await new Promise(r => setTimeout(r, 20)); assert.ok( - !computeCalls.some(c => c.baseBranch === undefined), - `non-uncommitted subscriptions must not trigger an uncommitted git diff, got: ${JSON.stringify(computeCalls)}`, + computeCalls.some(c => c.wd === workingDirectory.toString()), + `session-URI / session-changeset subscriptions must trigger a git diff against the working dir, got: ${JSON.stringify(computeCalls)}`, ); localService.unsubscribe(sessionChangesetUri, 'client-1'); - localService.unsubscribe(sessionResource, 'client-1'); + localService.unsubscribe(sessionResource, 'client-2'); }); test('restoreSession drains a pending uncommitted refresh deferred by an earlier addSubscriber', async () => { @@ -1927,17 +1995,22 @@ suite('AgentService (node dispatcher)', () => { const state = localService.stateManager.getSessionState(sessionResource.toString()); assert.ok(state); + // The session has no working directory, so `_attachGitState` + // treats it as transient and does NOT strip the two git-only + // catalogue entries. The Branch Changes entry receives the + // persisted diff counts seeded by the changeset coordinator. assert.deepStrictEqual(state!.summary.changesets, [ { - label: 'Uncommitted Changes', - uriTemplate: `${sessionResource.toString()}/changeset/uncommitted`, - }, - { - label: 'Session Changes', - uriTemplate: `${sessionResource.toString()}/changeset/session`, additions: 5, deletions: 2, files: 1, + label: 'Branch Changes', + uriTemplate: `${sessionResource.toString()}/changeset/session`, + }, + { + description: 'Show uncommitted changes in this session', + label: 'Uncommitted Changes', + uriTemplate: `${sessionResource.toString()}/changeset/uncommitted`, }, { label: 'This Turn', @@ -1975,16 +2048,19 @@ suite('AgentService (node dispatcher)', () => { const state = localService.stateManager.getSessionState(sessionResource.toString()); assert.ok(state); - // Catalogue is seeded by `_buildInitialSummary` / `restoreSession` - // (entries with no counts) — but no files were seeded. + // Catalogue is seeded by `_buildInitialSummary` / `restoreSession`. + // The session has no working directory, so `_attachGitState` does + // NOT strip the git-only entries — they remain advertised but + // without counts until a real compute lands. assert.deepStrictEqual(state!.summary.changesets, [ { - label: 'Uncommitted Changes', - uriTemplate: `${sessionResource.toString()}/changeset/uncommitted`, + label: 'Branch Changes', + uriTemplate: `${sessionResource.toString()}/changeset/session`, }, { - label: 'Session Changes', - uriTemplate: `${sessionResource.toString()}/changeset/session`, + description: 'Show uncommitted changes in this session', + label: 'Uncommitted Changes', + uriTemplate: `${sessionResource.toString()}/changeset/uncommitted`, }, { label: 'This Turn', @@ -2153,9 +2229,13 @@ suite('AgentService (node dispatcher)', () => { } function defaultCatalogue(sessionStr: string) { + // These tests have no working directory resolved, so + // `_attachGitState` treats it as transient and does NOT strip + // the two git-only entries. All three default entries are + // advertised (without counts) until a real compute lands. return [ - { label: 'Uncommitted Changes', uriTemplate: `${sessionStr}/changeset/uncommitted` }, - { label: 'Session Changes', uriTemplate: `${sessionStr}/changeset/session` }, + { label: 'Branch Changes', uriTemplate: `${sessionStr}/changeset/session` }, + { label: 'Uncommitted Changes', uriTemplate: `${sessionStr}/changeset/uncommitted`, description: 'Show uncommitted changes in this session' }, { label: 'This Turn', uriTemplate: `${sessionStr}/changeset/turn/{turnId}` }, ]; } diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 8b67981604ed6..311cfc5fb6373 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -53,6 +53,8 @@ class FakeChangesetService implements IAgentHostChangesetService { restoreStaticChangeset(_session: string, _kind: StaticChangesetKind, _diffs: readonly unknown[]): void { /* no-op */ } restorePersistedStaticChangesets(): { uncommitted?: undefined; session?: undefined } { return {}; } refreshUncommittedChangeset(): void { /* no-op */ } + refreshSessionChangeset(): void { /* no-op */ } + setTurnSubscriberProbe(): void { /* no-op */ } async computeTurnChangeset(session: string): Promise { return `${session}/changeset/turn/x`; } onToolCallEditsApplied(session: string, turnId: string): void { diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts index 78f2e9c7fcf0c..d345f4da14d82 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts @@ -60,7 +60,17 @@ const hasGit = (() => { teardown(function () { client.close(); if (tmpRoot) { - rmSync(tmpRoot, { recursive: true, force: true }); + try { + // On Windows, freshly-spawned `git` child processes and the + // agent host server may still hold handles on files under + // `tmpRoot` (e.g. `.git/index`) when teardown runs, causing + // `EBUSY`/`ENOTEMPTY`. `maxRetries` is Node's built-in + // workaround for exactly this case. + rmSync(tmpRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } catch { + // Best-effort: leave the temp dir for the OS to clean up + // rather than fail the test on a stale Windows file lock. + } } }); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index b0ea3c9b12e6e..9bd68256d410e 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -546,7 +546,7 @@ suite('ProtocolServerHandler', () => { summary: 'Session With Changesets', changesets: [ { - label: 'Session Changes', + label: 'Branch Changes', uriTemplate: `${sessionUri}/changeset/session`, additions: 5, deletions: 2, @@ -565,7 +565,7 @@ suite('ProtocolServerHandler', () => { const result = (resp as unknown as { result: ListSessionsResult }).result; assert.deepStrictEqual(result.items[0].changesets, [ { - label: 'Session Changes', + label: 'Branch Changes', uriTemplate: `${sessionUri}/changeset/session`, additions: 5, deletions: 2, From b34312d1f81a9d54fec17740691b4c73b08d64c7 Mon Sep 17 00:00:00 2001 From: "vs-code-engineering[bot]" <122617954+vs-code-engineering[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 12:12:33 +0000 Subject: [PATCH 10/10] [cherry-pick] Have the fork action ask the SessionsManagementService to reveal the newly forked session instead of the SessionManagementService reacting to it (#317279) Co-authored-by: vs-code-engineering[bot] --- .../copilotChatSessions.contribution.ts | 50 +- .../browser/sessionsManagementService.ts | 12 - .../browser/sessionsManagementService.test.ts | 37 - src/vs/sessions/sessions.common.main.ts | 2 +- .../chat/browser/actions/chatForkActions.ts | 392 +-- .../localAgentSessionsController.ts | 15 +- .../contrib/chat/browser/chat.contribution.ts | 2403 +--------------- .../chat/browser/chat.shared.contribution.ts | 2406 +++++++++++++++++ src/vs/workbench/workbench.common.main.ts | 1 + 9 files changed, 2670 insertions(+), 2648 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessions.contribution.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessions.contribution.ts index 580ffe10af28b..1fce44dacf55b 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessions.contribution.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessions.contribution.ts @@ -5,13 +5,19 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { CopilotChatSessionsProvider, COPILOT_MULTI_CHAT_SETTING, CLAUDE_CODE_ENABLED_SETTING, LOCAL_SESSION_ENABLED_SETTING } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js'; +import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { CopilotChatSessionsProvider, COPILOT_MULTI_CHAT_SETTING, CLAUDE_CODE_ENABLED_SETTING, LOCAL_SESSION_ENABLED_SETTING, LocalSessionType } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js'; import '../../copilotChatSessions/browser/copilotChatSessionsActions.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { localize } from '../../../../../nls.js'; +import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ForkConversationAction } from '../../../../../workbench/contrib/chat/browser/actions/chatForkActions.js'; +import { registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { raceTimeout } from '../../../../../base/common/async.js'; Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ id: 'sessions', @@ -63,3 +69,43 @@ class DefaultSessionsProviderContribution extends Disposable implements IWorkben } registerWorkbenchContribution2(DefaultSessionsProviderContribution.ID, DefaultSessionsProviderContribution, WorkbenchPhase.AfterRestored); + +registerAction2(class extends ForkConversationAction { + protected override _openForkedSession(instantiationService: IInstantiationService, parentSessionResource: URI, forkedSessionResource: URI): Promise { + return instantiationService.invokeFunction(async accessor => { + const sessionsManagementService = accessor.get(ISessionsManagementService); + const logService = accessor.get(ILogService); + + const parentSession = sessionsManagementService.getSession(parentSessionResource); + if (!parentSession) { + logService.error(`Parent session ${parentSessionResource.toString()} not found when forking conversation`); + return super._openForkedSession(instantiationService, parentSessionResource, forkedSessionResource); + } + + if (parentSession.sessionType !== LocalSessionType.id) { + return super._openForkedSession(instantiationService, parentSessionResource, forkedSessionResource); + } + + // Local sessions — wait for the forked session to appear, but + // bound the wait so a missing session does not hang forever. + if (!sessionsManagementService.getSession(forkedSessionResource)) { + let listener: IDisposable | undefined; + const appeared = await raceTimeout(new Promise(resolve => { + listener = sessionsManagementService.onDidChangeSessions(() => { + if (sessionsManagementService.getSession(forkedSessionResource)) { + resolve(true); + } + }); + }), 30_000); + listener?.dispose(); + + if (!appeared) { + logService.error(`Forked session ${forkedSessionResource.toString()} did not appear within timeout`); + return; + } + } + await sessionsManagementService.openSession(forkedSessionResource); + + }); + } +}); diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 7214f1d380a2a..c2cf08714dcd4 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -165,18 +165,6 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } } - // When new sessions appear, check if any are currently displayed in a - // chat widget. This handles cases where a session is opened in the - // widget without going through sessionsManagementService (e.g., fork). - if (e.added.length) { - for (const added of e.added) { - if (added.sessionId !== currentActive?.sessionId && this.chatWidgetService.getWidgetBySessionResource(added.resource)) { - this.setActiveSession(added); - return; - } - } - } - if (!currentActive) { return; } diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 23e2b77bc339b..eab0e9038122f 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -265,43 +265,6 @@ suite('SessionsManagementService', () => { assert.deepStrictEqual({ opened: chatWidgetService.opened.map(uri => uri.toString()), observed: agentSessionsService.observed.map(uri => uri.toString()) }, { opened: [session.resource.toString()], observed: [session.resource.toString()] }); }); - test('sets active session when added session is displayed in a chat widget', async () => { - const originalSession = stubSession({ sessionId: 'original', providerId: 'test' }); - const onDidChangeSessions = disposables.add(new Emitter()); - const provider = new class extends TestSessionsProvider { - override readonly onDidChangeSessions = onDidChangeSessions.event; - constructor() { super(originalSession); } - }; - - const instantiationService = disposables.add(new TestInstantiationService()); - const chatWidgetService = new TestChatWidgetService(); - const agentSessionsService = new TestAgentSessionsService(); - - instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); - instantiationService.stub(ILogService, new NullLogService()); - instantiationService.stub(IContextKeyService, disposables.add(new MockContextKeyService())); - instantiationService.stub(ISessionsProvidersService, new TestSessionsProvidersService([provider])); - instantiationService.stub(IUriIdentityService, { extUri: extUriBiasedIgnorePathCase }); - instantiationService.stub(IChatWidgetService, chatWidgetService); - instantiationService.stub(IAgentSessionsService, agentSessionsService); - instantiationService.stub(IProgressService, new TestProgressService()); - - const service = disposables.add(instantiationService.createInstance(SessionsManagementService)); - - // Open the original session so it becomes the active session - await service.openSession(originalSession.resource); - assert.strictEqual(service.activeSession.get()?.sessionId, 'original'); - - // Simulate fork: a new session is added and the chat widget displays it - const forkedSession = stubSession({ sessionId: 'forked', providerId: 'test' }); - chatWidgetService.setWidgetSessionResource(forkedSession.resource); - - onDidChangeSessions.fire({ added: [forkedSession], removed: [], changed: [] }); - - // The active session should now be the forked session - assert.strictEqual(service.activeSession.get()?.sessionId, 'forked'); - }); - test('does not change active session when added session is not displayed in any widget', async () => { const originalSession = stubSession({ sessionId: 'original', providerId: 'test' }); const onDidChangeSessions = disposables.add(new Emitter()); diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index f25f643f0808a..4846d408775b8 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -217,7 +217,7 @@ import '../workbench/contrib/notebook/browser/notebook.contribution.js'; import '../workbench/contrib/speech/browser/speech.contribution.js'; // Chat -import '../workbench/contrib/chat/browser/chat.contribution.js'; +import '../workbench/contrib/chat/browser/chat.shared.contribution.js'; //import '../workbench/contrib/inlineChat/browser/inlineChat.contribution.js'; import '../workbench/contrib/mcp/browser/mcp.contribution.js'; import '../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts index 39e4afbce1fd5..860e5dada9858 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts @@ -9,9 +9,9 @@ import { revive } from '../../../../../base/common/marshalling.js'; import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { localize, localize2 } from '../../../../../nls.js'; -import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { Action2, MenuId } from '../../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; -import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { ChatContextKeyExprs, ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatService, ResponseModelState } from '../../common/chatService/chatService.js'; import type { ISerializableChatData } from '../../common/model/chatModel.js'; @@ -21,235 +21,241 @@ import { getChatSessionType } from '../../common/model/chatUri.js'; import { CHAT_CATEGORY } from './chatActions.js'; import { ChatTreeItem, ChatViewPaneTarget, IChatWidgetService } from '../chat.js'; -export function registerChatForkActions() { - registerAction2(class ForkConversationAction extends Action2 { - constructor() { - super({ - id: 'workbench.action.chat.forkConversation', - title: localize2('chat.forkConversation.label', "Fork Conversation"), - tooltip: localize2('chat.forkConversation.tooltip', "Fork conversation from this point"), - f1: false, - category: CHAT_CATEGORY, - icon: Codicon.repoForked, - precondition: ChatContextKeys.enabled, - menu: [ - { - id: MenuId.ChatMessageCheckpoint, - group: 'navigation', - order: 3, - when: ContextKeyExpr.and( - ChatContextKeys.isRequest, - ChatContextKeys.isFirstRequest.negate(), - ContextKeyExpr.or( - ContextKeyExpr.or(ChatContextKeys.lockedToCodingAgent.negate(), ChatContextKeyExprs.isAgentHostSession), - ChatContextKeys.chatSessionSupportsFork - ) +export class ForkConversationAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.forkConversation', + title: localize2('chat.forkConversation.label', "Fork Conversation"), + tooltip: localize2('chat.forkConversation.tooltip', "Fork conversation from this point"), + f1: false, + category: CHAT_CATEGORY, + icon: Codicon.repoForked, + precondition: ChatContextKeys.enabled, + menu: [ + { + id: MenuId.ChatMessageCheckpoint, + group: 'navigation', + order: 3, + when: ContextKeyExpr.and( + ChatContextKeys.isRequest, + ChatContextKeys.isFirstRequest.negate(), + ContextKeyExpr.or( + ContextKeyExpr.or(ChatContextKeys.lockedToCodingAgent.negate(), ChatContextKeyExprs.isAgentHostSession), + ChatContextKeys.chatSessionSupportsFork ) - } - ] - }); - } - - async run(accessor: ServicesAccessor, ...args: unknown[]) { - const chatWidgetService = accessor.get(IChatWidgetService); - const chatService = accessor.get(IChatService); - const chatSessionsService = accessor.get(IChatSessionsService); - const forkedTitlePrefix = localize('chat.forked.titlePrefix', "Forked: "); - - // When invoked via /fork slash command, args[0] is a URI (sessionResource). - // Fork at the last request in that session. - if (URI.isUri(args[0])) { - const sourceSessionResource = args[0]; - - // Check if this is a contributed session that supports forking - const contentProviderSchemes = chatSessionsService.getContentProviderSchemes(); - if (contentProviderSchemes.includes(getChatSessionType(sourceSessionResource))) { - return await this.forkContributedChatSession(sourceSessionResource, undefined, false, chatSessionsService, chatWidgetService); - } - - const chatModel = chatService.getSession(sourceSessionResource); - if (!chatModel) { - return; - } - - const serializedData = chatModel.toJSON(); - if (serializedData.requests.length === 0) { - return; + ) } + ] + }); + } - const cleanData = revive(JSON.parse(JSON.stringify(serializedData))) as ISerializableChatData; - cleanData.sessionId = generateUuid(); - const forkTimestamp = Date.now(); - cleanData.creationDate = forkTimestamp; - cleanData.customTitle = chatModel.title.startsWith(forkedTitlePrefix) - ? chatModel.title - : localize('chat.forked.title', "Forked: {0}", chatModel.title); - for (const [index, req] of cleanData.requests.entries()) { - req.shouldBeRemovedOnSend = undefined; - req.isHidden = undefined; - // Generate fresh IDs so the tree doesn't reuse stale DOM from the source session - req.requestId = generateUuid(); - req.responseId = req.responseId ? generateUuid() : undefined; - req.timestamp = forkTimestamp + index; - if (req.response) { - req.modelState = { value: ResponseModelState.Complete, completedAt: forkTimestamp + index }; - } - } - - const modelRef = chatService.loadSessionFromData(cleanData, 'ChatForkActions#forkCleanSession'); - - // Defer navigation until after the slash command flow completes. - const newSessionResource = modelRef.object.sessionResource; - setTimeout(async () => { - try { - await chatWidgetService.openSession(newSessionResource, ChatViewPaneTarget); - } finally { - modelRef.dispose(); - } - }, 0); - return; - } - - // When invoked from the checkpoint menu, args[0] is a ChatTreeItem. - const arg = args[0] as { element?: unknown; context?: unknown; item?: unknown } | undefined; - let item: ChatTreeItem | undefined = isChatTreeItem(arg) - ? arg - : isChatTreeItem(arg?.element) - ? arg.element - : isChatTreeItem(arg?.context) - ? arg.context - : isChatTreeItem(arg?.item) - ? arg.item - : undefined; - const widget = (item && chatWidgetService.getWidgetBySessionResource(item.sessionResource)) || chatWidgetService.lastFocusedWidget; - if (!isResponseVM(item) && !isRequestVM(item)) { - item = widget?.getFocus(); - } - - if (!item) { - return; - } + async run(accessor: ServicesAccessor, ...args: unknown[]) { + const chatWidgetService = accessor.get(IChatWidgetService); + const instantiationService = accessor.get(IInstantiationService); + const chatService = accessor.get(IChatService); + const chatSessionsService = accessor.get(IChatSessionsService); + const forkedTitlePrefix = localize('chat.forked.titlePrefix', "Forked: "); - const sessionResource = widget?.viewModel?.sessionResource ?? (isChatTreeItem(item) ? item.sessionResource : undefined); - if (!sessionResource) { - return; - } - - // Get all requests and find the target request index - const targetRequestId = isRequestVM(item) ? item.id : isResponseVM(item) ? item.requestId : undefined; - if (!targetRequestId) { - return; - } + // When invoked via /fork slash command, args[0] is a URI (sessionResource). + // Fork at the last request in that session. + if (URI.isUri(args[0])) { + const sourceSessionResource = args[0]; // Check if this is a contributed session that supports forking const contentProviderSchemes = chatSessionsService.getContentProviderSchemes(); - if (contentProviderSchemes.includes(getChatSessionType(sessionResource))) { - const contributedSession = await chatSessionsService.getOrCreateChatSession(sessionResource, CancellationToken.None); - let request = contributedSession.history.find((entry): entry is IChatSessionRequestHistoryItem => entry.type === 'request' && entry.id === targetRequestId); - if (!request) { - const chatModel = chatService.getSession(sessionResource); - const serializedData = chatModel?.toJSON(); - for (const [, entry] of serializedData?.requests.entries() ?? []) { - if (entry.requestId === targetRequestId) { - request = { - id: entry.requestId, - type: 'request', - prompt: typeof entry.message === 'string' ? entry.message : entry.message.text, - participant: entry.agent?.id ?? '', - variableData: entry.variableData, - modelId: entry.modelId, - }; - break; - } - } - } - return await this.forkContributedChatSession(sessionResource, request, true, chatSessionsService, chatWidgetService); + if (contentProviderSchemes.includes(getChatSessionType(sourceSessionResource))) { + return await this.forkContributedChatSession(sourceSessionResource, undefined, false, chatSessionsService, chatWidgetService); } - const chatModel = chatService.getSession(sessionResource); + const chatModel = chatService.getSession(sourceSessionResource); if (!chatModel) { return; } - // Export the full session data and truncate to include only requests up to and including the target const serializedData = chatModel.toJSON(); - const isRequestItem = isRequestVM(item); - let targetIndex = -1; - if (widget?.viewModel) { - let requestIndex = -1; - for (const entry of widget.viewModel.getItems()) { - if (isRequestVM(entry)) { - requestIndex += 1; - } - if (entry.id === item?.id) { - targetIndex = isRequestVM(entry) ? Math.max(0, requestIndex - 1) : requestIndex; - break; - } - } - } - if (targetIndex < 0) { - const requestIndex = chatModel.getRequests().findIndex(r => r.id === targetRequestId); - targetIndex = isRequestItem ? Math.max(0, requestIndex - 1) : requestIndex; - } - if (targetIndex < 0) { + if (serializedData.requests.length === 0) { return; } - const forkedData = revive(JSON.parse(JSON.stringify({ - ...serializedData, - requests: serializedData.requests.slice(0, targetIndex + 1), - }))) as ISerializableChatData; - forkedData.sessionId = generateUuid(); - const forkedTimestamp = Date.now(); - forkedData.creationDate = forkedTimestamp; - forkedData.customTitle = chatModel.title.startsWith(forkedTitlePrefix) + const cleanData = revive(JSON.parse(JSON.stringify(serializedData))) as ISerializableChatData; + cleanData.sessionId = generateUuid(); + const forkTimestamp = Date.now(); + cleanData.creationDate = forkTimestamp; + cleanData.customTitle = chatModel.title.startsWith(forkedTitlePrefix) ? chatModel.title : localize('chat.forked.title', "Forked: {0}", chatModel.title); - for (const [index, req] of forkedData.requests.entries()) { + for (const [index, req] of cleanData.requests.entries()) { req.shouldBeRemovedOnSend = undefined; req.isHidden = undefined; // Generate fresh IDs so the tree doesn't reuse stale DOM from the source session req.requestId = generateUuid(); req.responseId = req.responseId ? generateUuid() : undefined; - req.timestamp = forkedTimestamp + index; + req.timestamp = forkTimestamp + index; if (req.response) { - req.modelState = { value: ResponseModelState.Complete, completedAt: forkedTimestamp + index }; + req.modelState = { value: ResponseModelState.Complete, completedAt: forkTimestamp + index }; } } - const modelRef = chatService.loadSessionFromData(forkedData, 'ChatForkActions#forkSession'); + const modelRef = chatService.loadSessionFromData(cleanData, 'ChatForkActions#forkCleanSession'); - if (!modelRef) { - return; - } + // Defer navigation until after the slash command flow completes. + const newSessionResource = modelRef.object.sessionResource; + setTimeout(async () => { + try { + await this._openForkedSession(instantiationService, chatModel.sessionResource, newSessionResource); + } finally { + modelRef.dispose(); + } + }, 0); + return; + } - // Navigate to the new session in the chat view pane - try { - const newSessionResource = modelRef.object.sessionResource; - await chatWidgetService.openSession(newSessionResource, ChatViewPaneTarget); - } finally { - modelRef.dispose(); + // When invoked from the checkpoint menu, args[0] is a ChatTreeItem. + const arg = args[0] as { element?: unknown; context?: unknown; item?: unknown } | undefined; + let item: ChatTreeItem | undefined = isChatTreeItem(arg) + ? arg + : isChatTreeItem(arg?.element) + ? arg.element + : isChatTreeItem(arg?.context) + ? arg.context + : isChatTreeItem(arg?.item) + ? arg.item + : undefined; + const widget = (item && chatWidgetService.getWidgetBySessionResource(item.sessionResource)) || chatWidgetService.lastFocusedWidget; + if (!isResponseVM(item) && !isRequestVM(item)) { + item = widget?.getFocus(); + } + + if (!item) { + return; + } + + const sessionResource = widget?.viewModel?.sessionResource ?? (isChatTreeItem(item) ? item.sessionResource : undefined); + if (!sessionResource) { + return; + } + + // Get all requests and find the target request index + const targetRequestId = isRequestVM(item) ? item.id : isResponseVM(item) ? item.requestId : undefined; + if (!targetRequestId) { + return; + } + + // Check if this is a contributed session that supports forking + const contentProviderSchemes = chatSessionsService.getContentProviderSchemes(); + if (contentProviderSchemes.includes(getChatSessionType(sessionResource))) { + const contributedSession = await chatSessionsService.getOrCreateChatSession(sessionResource, CancellationToken.None); + let request = contributedSession.history.find((entry): entry is IChatSessionRequestHistoryItem => entry.type === 'request' && entry.id === targetRequestId); + if (!request) { + const chatModel = chatService.getSession(sessionResource); + const serializedData = chatModel?.toJSON(); + for (const [, entry] of serializedData?.requests.entries() ?? []) { + if (entry.requestId === targetRequestId) { + request = { + id: entry.requestId, + type: 'request', + prompt: typeof entry.message === 'string' ? entry.message : entry.message.text, + participant: entry.agent?.id ?? '', + variableData: entry.variableData, + modelId: entry.modelId, + }; + break; + } + } } + return await this.forkContributedChatSession(sessionResource, request, true, chatSessionsService, chatWidgetService); } - private pendingFork = new Map>(); + const chatModel = chatService.getSession(sessionResource); + if (!chatModel) { + return; + } - private async forkContributedChatSession(sourceSessionResource: URI, request: IChatSessionRequestHistoryItem | undefined, openForkedSessionImmediately: boolean, chatSessionsService: IChatSessionsService, chatWidgetService: IChatWidgetService) { - const pendingKey = `${sourceSessionResource.toString()}@${request?.id ?? 'full'}`; - const pending = this.pendingFork.get(pendingKey); - if (pending) { - return pending; + // Export the full session data and truncate to include only requests up to and including the target + const serializedData = chatModel.toJSON(); + const isRequestItem = isRequestVM(item); + let targetIndex = -1; + if (widget?.viewModel) { + let requestIndex = -1; + for (const entry of widget.viewModel.getItems()) { + if (isRequestVM(entry)) { + requestIndex += 1; + } + if (entry.id === item?.id) { + targetIndex = isRequestVM(entry) ? Math.max(0, requestIndex - 1) : requestIndex; + break; + } } + } + if (targetIndex < 0) { + const requestIndex = chatModel.getRequests().findIndex(r => r.id === targetRequestId); + targetIndex = isRequestItem ? Math.max(0, requestIndex - 1) : requestIndex; + } + if (targetIndex < 0) { + return; + } - const forkPromise = forkContributedChatSession(sourceSessionResource, request, openForkedSessionImmediately, chatSessionsService, chatWidgetService); - this.pendingFork.set(pendingKey, forkPromise); - try { - await forkPromise; - } finally { - this.pendingFork.delete(pendingKey); + const forkedData = revive(JSON.parse(JSON.stringify({ + ...serializedData, + requests: serializedData.requests.slice(0, targetIndex + 1), + }))) as ISerializableChatData; + forkedData.sessionId = generateUuid(); + const forkedTimestamp = Date.now(); + forkedData.creationDate = forkedTimestamp; + forkedData.customTitle = chatModel.title.startsWith(forkedTitlePrefix) + ? chatModel.title + : localize('chat.forked.title', "Forked: {0}", chatModel.title); + for (const [index, req] of forkedData.requests.entries()) { + req.shouldBeRemovedOnSend = undefined; + req.isHidden = undefined; + // Generate fresh IDs so the tree doesn't reuse stale DOM from the source session + req.requestId = generateUuid(); + req.responseId = req.responseId ? generateUuid() : undefined; + req.timestamp = forkedTimestamp + index; + if (req.response) { + req.modelState = { value: ResponseModelState.Complete, completedAt: forkedTimestamp + index }; } } - }); + + const modelRef = chatService.loadSessionFromData(forkedData, 'ChatForkActions#forkSession'); + + if (!modelRef) { + return; + } + + // Navigate to the new session in the chat view pane + try { + const newSessionResource = modelRef.object.sessionResource; + await this._openForkedSession(instantiationService, chatModel.sessionResource, newSessionResource); + } finally { + modelRef.dispose(); + } + } + + protected async _openForkedSession(instantiationService: IInstantiationService, parentSessionResource: URI, forkedSessionResource: URI): Promise { + await instantiationService.invokeFunction(async accessor => { + const chatWidgetService = accessor.get(IChatWidgetService); + await chatWidgetService.openSession(forkedSessionResource, ChatViewPaneTarget); + }); + } + + private pendingFork = new Map>(); + + private async forkContributedChatSession(sourceSessionResource: URI, request: IChatSessionRequestHistoryItem | undefined, openForkedSessionImmediately: boolean, chatSessionsService: IChatSessionsService, chatWidgetService: IChatWidgetService) { + const pendingKey = `${sourceSessionResource.toString()}@${request?.id ?? 'full'}`; + const pending = this.pendingFork.get(pendingKey); + if (pending) { + return pending; + } + + const forkPromise = forkContributedChatSession(sourceSessionResource, request, openForkedSessionImmediately, chatSessionsService, chatWidgetService); + this.pendingFork.set(pendingKey, forkPromise); + try { + await forkPromise; + } finally { + this.pendingFork.delete(pendingKey); + } + } } async function forkContributedChatSession(sourceSessionResource: URI, request: IChatSessionRequestHistoryItem | undefined, openForkedSessionImmediately: boolean, chatSessionsService: IChatSessionsService, chatWidgetService: IChatWidgetService) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts index 175b684ff0a0d..1c774c1ee581b 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts @@ -60,20 +60,31 @@ export class LocalAgentsSessionsController extends Disposable implements IChatSe async refresh(token: CancellationToken): Promise { const newItems = await this.provideChatSessionItems(token); + const newResources = new ResourceSet(newItems.map(i => i.resource)); const addedOrUpdated: LocalChatSessionItem[] = []; + const removed: URI[] = []; + for (const item of newItems) { if (!this._items.has(item.resource)) { addedOrUpdated.push(item); } } + for (const resource of this._items.keys()) { + if (!newResources.has(resource)) { + removed.push(resource); + } + } this._items.clear(); for (const item of newItems) { this._items.set(item.resource, item); } - if (addedOrUpdated.length > 0) { - this._onDidChangeChatSessionItems.fire({ addedOrUpdated }); + if (addedOrUpdated.length > 0 || removed.length > 0) { + this._onDidChangeChatSessionItems.fire({ + ...(addedOrUpdated.length > 0 ? { addedOrUpdated } : undefined), + ...(removed.length > 0 ? { removed } : undefined), + }); } } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index dc3832ca3c6bd..4647b80a1de43 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -3,2406 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; -import { Schemas } from '../../../../base/common/network.js'; -import { autorun, observableFromEvent } from '../../../../base/common/observable.js'; -import { isMacintosh } from '../../../../base/common/platform.js'; -import { PolicyCategory } from '../../../../base/common/policy.js'; -import { AgentHostAhpJsonlLoggingSettingId, AgentHostClaudeAgentSdkPathSettingId, AgentHostCustomTerminalToolEnabledSettingId, AgentHostEnabledSettingId, AgentHostIpcLoggingSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOutfileSettingId } from '../../../../platform/agentHost/common/agentService.js'; -import { AgentNetworkFilterService, IAgentNetworkFilterService } from '../../../../platform/networkFilter/common/networkFilterService.js'; -import { AgentNetworkDomainSettingId } from '../../../../platform/networkFilter/common/settings.js'; -import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../platform/sandbox/common/settings.js'; -import { registerEditorFeature } from '../../../../editor/common/editorFeatures.js'; -import * as nls from '../../../../nls.js'; -import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; -import { Extensions as ConfigurationExtensions, ConfigurationScope, IConfigurationNode, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; -import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; -import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { McpAccessValue, McpAutoStartValue, mcpAccessConfig, mcpAutoStartConfig, mcpGalleryServiceEnablementConfig, mcpGalleryServiceUrlConfig, mcpAppsEnabledConfig } from '../../../../platform/mcp/common/mcpManagement.js'; -import product from '../../../../platform/product/common/product.js'; -import { Registry } from '../../../../platform/registry/common/platform.js'; -import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/editor.js'; -import { type ConfigurationKeyValuePairs, Extensions, IConfigurationMigrationRegistry } from '../../../common/configuration.js'; -import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; -import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor.js'; -import { IWorkbenchAssignmentService } from '../../../services/assignment/common/assignmentService.js'; -import { ChatEntitlement, IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; -import { IEditorService } from '../../../services/editor/common/editorService.js'; -import { IEditorResolverService, RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js'; -import { IPathService } from '../../../services/path/common/pathService.js'; -import { IViewsService } from '../../../services/views/common/viewsService.js'; -import { AddConfigurationType, AssistedTypes } from '../../mcp/browser/mcpCommandsAddConfiguration.js'; -import { allDiscoverySources, discoverySourceSettingsLabel, McpCollisionBehavior, mcpDiscoverySection, mcpServerCollisionBehaviorSection, mcpServerSamplingSection } from '../../mcp/common/mcpConfiguration.js'; -import { ChatAgentNameService, ChatAgentService, IChatAgentNameService, IChatAgentService } from '../common/participants/chatAgents.js'; -import { CodeMapperService, ICodeMapperService } from '../common/editing/chatCodeMapperService.js'; -import '../common/widget/chatColors.js'; -import { IChatEditingService } from '../common/editing/chatEditingService.js'; -import { IChatLayoutService } from '../common/widget/chatLayoutService.js'; -import { ChatModeService, IChatMode, IChatModeService, IChatModes } from '../common/chatModes.js'; -import { ChatResponseResourceFileSystemProvider, ChatResponseResourceWorkbenchContribution, IChatResponseResourceFileSystemProvider } from '../common/widget/chatResponseResourceFileSystemProvider.js'; -import { IChatService } from '../common/chatService/chatService.js'; -import { ChatService } from '../common/chatService/chatServiceImpl.js'; -import { IChatSessionsService } from '../common/chatSessionsService.js'; -import { ChatSlashCommandService, IChatSlashCommandService } from '../common/participants/chatSlashCommands.js'; -import { ChatArtifactsService, IChatArtifactsService } from '../common/tools/chatArtifactsService.js'; -import { ChatTodoListService, IChatTodoListService } from '../common/tools/chatTodoListService.js'; -import { ChatTransferService, IChatTransferService } from '../common/model/chatTransferService.js'; -import { IChatVariablesService } from '../common/attachments/chatVariables.js'; -import { ChatWidgetHistoryService, IChatWidgetHistoryService } from '../common/widget/chatWidgetHistoryService.js'; -import { ChatAgentLocation, ChatConfiguration, ChatNotificationMode, ChatPermissionLevel } from '../common/constants.js'; -import { ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService } from '../common/ignoredFiles.js'; -import { ILanguageModelsService, LanguageModelsService } from '../common/languageModels.js'; -import { ILanguageModelStatsService, LanguageModelStatsService } from '../common/languageModelStats.js'; -import { ILanguageModelToolsConfirmationService } from '../common/tools/languageModelToolsConfirmationService.js'; -import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; -import { ChatToolRiskAssessmentService, IChatToolRiskAssessmentService } from './tools/chatToolRiskAssessmentService.js'; -import { agentPluginDiscoveryRegistry, IAgentPluginService } from '../common/plugins/agentPluginService.js'; -import { ChatPromptFilesExtensionPointHandler } from '../common/promptSyntax/chatPromptFilesContribution.js'; -import { isTildePath, PromptsConfig } from '../common/promptSyntax/config/config.js'; -import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION, DEFAULT_SKILL_SOURCE_FOLDERS, AGENTS_SOURCE_FOLDER, AGENT_FILE_EXTENSION, SKILL_FILENAME, CLAUDE_AGENTS_SOURCE_FOLDER, DEFAULT_HOOK_FILE_PATHS, DEFAULT_INSTRUCTIONS_SOURCE_FOLDERS, COPILOT_USER_AGENTS_SOURCE_FOLDER } from '../common/promptSyntax/config/promptFileLocations.js'; -import { PromptLanguageFeaturesProvider } from './promptSyntax/promptFileContributions.js'; -import { AGENT_DOCUMENTATION_URL, INSTRUCTIONS_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, SKILL_DOCUMENTATION_URL, HOOK_DOCUMENTATION_URL, PromptsType, PromptFileSource } from '../common/promptSyntax/promptTypes.js'; -import { hookFileSchema, HOOK_SCHEMA_URI } from '../common/promptSyntax/hookSchema.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js'; -import { IPromptsService } from '../common/promptSyntax/service/promptsService.js'; -import { PromptsService } from '../common/promptSyntax/service/promptsServiceImpl.js'; -import { LanguageModelToolsExtensionPointHandler } from '../common/tools/languageModelToolsContribution.js'; -import './telemetry/chatModelCountTelemetry.js'; -import { BuiltinToolsContribution } from '../common/tools/builtinTools/tools.js'; -import { RenameToolContribution } from './tools/renameTool.js'; -import { UsagesToolContribution } from './tools/usagesTool.js'; -import { IVoiceChatService, VoiceChatService } from '../common/voiceChatService.js'; -import { registerChatAccessibilityActions } from './actions/chatAccessibilityActions.js'; -import { AgentChatAccessibilityHelp, EditsChatAccessibilityHelp, PanelChatAccessibilityHelp, QuickChatAccessibilityHelp } from './actions/chatAccessibilityHelp.js'; -import { ModeOpenChatGlobalAction, registerChatActions } from './actions/chatActions.js'; -import { CodeBlockActionRendering, registerChatCodeBlockActions, registerChatCodeCompareBlockActions } from './actions/chatCodeblockActions.js'; -import { ChatContextContributions } from './actions/chatContext.js'; -import { registerChatContextActions } from './actions/chatContextActions.js'; -import { ChatCopyActionRendering, registerChatCopyActions } from './actions/chatCopyActions.js'; -import { registerChatDeveloperActions } from './actions/chatDeveloperActions.js'; -import { registerChatExecuteActions } from './actions/chatExecuteActions.js'; -import { registerChatFileTreeActions } from './actions/chatFileTreeActions.js'; -import { ChatGettingStartedContribution } from './actions/chatGettingStarted.js'; -import { registerChatForkActions } from './actions/chatForkActions.js'; -import { registerChatExportActions } from './actions/chatImportExport.js'; -import { registerLanguageModelActions } from './actions/chatLanguageModelActions.js'; -import { registerChatPluginActions } from './actions/chatPluginActions.js'; -import { registerMoveActions } from './actions/chatMoveActions.js'; -import { registerNewChatActions } from './actions/chatNewActions.js'; -import { registerChatPromptNavigationActions } from './actions/chatPromptNavigationActions.js'; -import { registerChatQueueActions } from './actions/chatQueueActions.js'; -import { registerQuickChatActions } from './actions/chatQuickInputActions.js'; -import { ChatAgentRecommendation } from './actions/chatAgentRecommendationActions.js'; -import { registerChatTitleActions } from './actions/chatTitleActions.js'; -import { registerChatElicitationActions } from './actions/chatElicitationActions.js'; -import { registerChatToolActions } from './actions/chatToolActions.js'; -import { ChatTransferContribution } from './actions/chatTransfer.js'; -import { registerChatOpenAgentDebugPanelAction } from './actions/chatOpenAgentDebugPanelAction.js'; -import { IChatDebugService } from '../common/chatDebugService.js'; -import { ChatDebugServiceImpl } from '../common/chatDebugServiceImpl.js'; -import { ChatDebugEditor } from './chatDebug/chatDebugEditor.js'; -import { PromptsDebugContribution } from './promptsDebugContribution.js'; -import { ChatDebugEditorInput, ChatDebugEditorInputSerializer } from './chatDebug/chatDebugEditorInput.js'; -import './agentSessions/agentSessions.contribution.js'; +import { ForkConversationAction } from './actions/chatForkActions.js'; -import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; - -import { ChatViewId, IChatAccessibilityService, IChatCodeBlockContextProviderService, IChatWidgetService, IQuickChatService, isIChatResourceViewContext, isIChatViewViewContext } from './chat.js'; -import { ChatAccessibilityService } from './accessibility/chatAccessibilityService.js'; -import './attachments/chatAttachmentModel.js'; -import './widget/input/chatInputNotificationService.js'; -import { ChatAttachmentResolveService, IChatAttachmentResolveService } from './attachments/chatAttachmentResolveService.js'; -import { ChatAttachmentWidgetRegistry, IChatAttachmentWidgetRegistry } from './attachments/chatAttachmentWidgetRegistry.js'; -import { ChatMarkdownAnchorService, IChatMarkdownAnchorService } from './widget/chatContentParts/chatMarkdownAnchorService.js'; -import { ChatContextPickService, IChatContextPickService } from './attachments/chatContextPickService.js'; -import { ChatInputBoxContentProvider } from './widget/input/editor/chatEditorInputContentProvider.js'; -import { ChatEditingEditorAccessibility } from './chatEditing/chatEditingEditorAccessibility.js'; -import { registerChatEditorActions } from './chatEditing/chatEditingEditorActions.js'; -import { ChatEditingEditorContextKeys } from './chatEditing/chatEditingEditorContextKeys.js'; -import { ChatEditingEditorOverlay } from './chatEditing/chatEditingEditorOverlay.js'; -import { ChatEditingService } from './chatEditing/chatEditingServiceImpl.js'; -import { ChatEditingNotebookFileSystemProviderContrib } from './chatEditing/notebook/chatEditingNotebookFileSystemProvider.js'; -import { ChatEditor, IChatEditorOptions } from './widgetHosts/editor/chatEditor.js'; -import { ChatEditorInput, ChatEditorInputSerializer } from './widgetHosts/editor/chatEditorInput.js'; -import { ChatLayoutService } from './widget/chatLayoutService.js'; -import { ChatLanguageModelsDataContribution, LanguageModelsConfigurationService } from './languageModelsConfigurationService.js'; -import './chatManagement/chatManagement.contribution.js'; -import './aiCustomization/aiCustomizationWorkspaceService.js'; -import './aiCustomization/customizationHarnessService.js'; -import './aiCustomization/aiCustomizationManagement.contribution.js'; -import './aiCustomization/aiCustomizationItemsModel.js'; - -import { ChatOutputRendererService, IChatOutputRendererService } from './chatOutputItemRenderer.js'; -import { ChatCompatibilityNotifier, ChatExtensionPointHandler } from './chatParticipant.contribution.js'; -import { ChatPasteProvidersFeature } from './widget/input/editor/chatPasteProviders.js'; -import { QuickChatService } from './widgetHosts/chatQuick.js'; -import { ChatResponseAccessibleView } from './accessibility/chatResponseAccessibleView.js'; -import { ChatTerminalOutputAccessibleView } from './accessibility/chatTerminalOutputAccessibleView.js'; -import { ChatSetupContribution, ChatTeardownContribution } from './chatSetup/chatSetupContributions.js'; -import { HasByokModelsContribution } from './hasByokModelsContribution.js'; -import { ChatStatusBarEntry } from './chatStatus/chatStatusEntry.js'; -import { ChatVariablesService } from './attachments/chatVariables.js'; -import { ChatWidget } from './widget/chatWidget.js'; -import { ChatCodeBlockContextProviderService } from './codeBlockContextProviderService.js'; -import { ChatDynamicVariableModel } from './attachments/chatDynamicVariables.js'; -import { ChatImplicitContextContribution } from './attachments/chatImplicitContext.js'; -import './widget/input/editor/chatInputCompletions.js'; -import './widget/input/editor/agentHostInputCompletions.js'; -import './widget/input/editor/chatInputEditorContrib.js'; -import './widget/input/editor/chatInputEditorHover.js'; -import { LanguageModelToolsConfirmationService } from './tools/languageModelToolsConfirmationService.js'; -import { LanguageModelToolsService, globalAutoApproveDescription } from './tools/languageModelToolsService.js'; -import { IToolResultCompressor } from '../common/tools/toolResultCompressor.js'; -import { ToolResultCompressorService } from './tools/toolResultCompressorService.js'; -import { AgentPluginService, ConfiguredAgentPluginDiscovery, CopilotCliAgentPluginDiscovery, ExtensionAgentPluginDiscovery, MarketplaceAgentPluginDiscovery } from '../common/plugins/agentPluginServiceImpl.js'; -import { IAgentPluginRepositoryService } from '../common/plugins/agentPluginRepositoryService.js'; -import { IPluginInstallService } from '../common/plugins/pluginInstallService.js'; -import { IPluginMarketplaceService, PluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js'; -import { WorkspacePluginSettingsService, IWorkspacePluginSettingsService } from '../common/plugins/workspacePluginSettingsService.js'; -import { AgentPluginRecommendations } from './claudePluginRecommendations.js'; -import { AgentPluginEditor } from './agentPluginEditor/agentPluginEditor.js'; -import { AgentPluginEditorInput } from './agentPluginEditor/agentPluginEditorInput.js'; -import { AgentPluginRepositoryService } from './agentPluginRepositoryService.js'; -import { BrowserPluginGitCommandService } from './pluginGitCommandService.js'; -import { IPluginGitService } from '../common/plugins/pluginGitService.js'; -import { PluginInstallService } from './pluginInstallService.js'; -import { PluginAutoUpdate } from './pluginAutoUpdate.js'; -import './promptSyntax/promptCodingAgentActionContribution.js'; -import './promptSyntax/promptToolsCodeLensProvider.js'; -import { ChatSessionOptionSlashCommandsContribution, ChatSlashCommandsContribution } from './chatSlashCommands.js'; -import './planReviewFeedback/planReviewFeedbackEditorContribution.js'; -import { registerPlanReviewFeedbackEditorActions } from './planReviewFeedback/planReviewFeedbackEditorActions.js'; -import { IPlanReviewFeedbackService, PlanReviewFeedbackService } from './planReviewFeedback/planReviewFeedbackService.js'; -import { PluginUrlHandler } from './pluginUrlHandler.js'; -import { PromptUrlHandler } from './promptSyntax/promptUrlHandler.js'; -import { ConfigureToolSets, UserToolSetsContributions } from './tools/toolSetsContribution.js'; -import { ChatViewsWelcomeHandler } from './viewsWelcome/chatViewsWelcomeHandler.js'; -import { ChatWidgetService } from './widget/chatWidgetService.js'; -import { ILanguageModelsConfigurationService } from '../common/languageModelsConfiguration.js'; -import { ChatWindowNotifier } from './chatWindowNotifier.js'; -import { ChatRepoInfoContribution } from './chatRepoInfo.js'; -import { VALID_PROMPT_FOLDER_PATTERN } from '../common/promptSyntax/utils/promptFilesLocator.js'; -import { ChatTipService, IChatTipService } from './chatTipService.js'; -import { ChatQueuePickerRendering } from './widget/input/chatQueuePickerActionItem.js'; -import { ExploreAgentDefaultModel } from './exploreAgentDefaultModel.js'; -import { PlanAgentDefaultModel } from './planAgentDefaultModel.js'; -import { UtilityModelContribution, UtilitySmallModelContribution } from './utilityModelContribution.js'; -import { ChatImageCarouselService, IChatImageCarouselService } from './chatImageCarouselService.js'; -import { browserChatToolReferenceNames } from '../../browserView/common/browserChatToolReferenceNames.js'; - -CommandsRegistry.registerCommand('_chat.notifyQuestionCarouselAnswer', (accessor: ServicesAccessor, resolveId: string, answers?: import('../common/chatService/chatService.js').IChatQuestionAnswers) => { - accessor.get(IChatService).notifyQuestionCarouselAnswer('', resolveId, answers); -}); - -const toolReferenceNameEnumValues: string[] = []; -const toolReferenceNameEnumDescriptions: string[] = []; - -// Register JSON schema for hook files -const jsonContributionRegistry = Registry.as(JSONExtensions.JSONContribution); -jsonContributionRegistry.registerSchema(HOOK_SCHEMA_URI, hookFileSchema); - -// Register configuration -const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); -configurationRegistry.registerConfiguration({ - id: 'chatSidebar', - title: nls.localize('interactiveSessionConfigurationTitle', "Chat"), - type: 'object', - properties: { - 'chat.experimentalSessionsWindowOverride': { - type: 'boolean', - description: nls.localize('chat.experimentalSessionsWindowOverride', "When true, enables sessions-window-specific behavior for extensions."), - default: false, - tags: ['experimental'], - agentsWindow: { default: true }, - }, - 'chat.fontSize': { - type: 'number', - description: nls.localize('chat.fontSize', "Controls the font size in pixels in chat messages."), - default: 13, - minimum: 6, - maximum: 100 - }, - 'chat.fontFamily': { - type: 'string', - description: nls.localize('chat.fontFamily', "Controls the font family in chat messages."), - default: 'default' - }, - 'chat.editor.fontSize': { - type: 'number', - description: nls.localize('interactiveSession.editor.fontSize', "Controls the font size in pixels in chat codeblocks."), - default: isMacintosh ? 12 : 14, - }, - 'chat.editor.fontFamily': { - type: 'string', - description: nls.localize('interactiveSession.editor.fontFamily', "Controls the font family in chat codeblocks."), - default: 'default' - }, - 'chat.editor.fontWeight': { - type: 'string', - description: nls.localize('interactiveSession.editor.fontWeight', "Controls the font weight in chat codeblocks."), - default: 'default' - }, - 'chat.editor.wordWrap': { - type: 'string', - description: nls.localize('interactiveSession.editor.wordWrap', "Controls whether lines should wrap in chat codeblocks."), - default: 'off', - enum: ['on', 'off'] - }, - 'chat.editor.lineHeight': { - type: 'number', - description: nls.localize('interactiveSession.editor.lineHeight', "Controls the line height in pixels in chat codeblocks. Use 0 to compute the line height from the font size."), - default: 0 - }, - [ChatConfiguration.AgentStatusEnabled]: { - type: 'string', - enum: ['hidden', 'badge', 'compact'], - enumDescriptions: [ - nls.localize('chat.agentsControl.hidden', "The agent status indicator is hidden from the title bar."), - nls.localize('chat.agentsControl.badge', "Shows the agent status as a badge next to the command center."), - nls.localize('chat.agentsControl.compact', "Replaces the command center search box with a compact agent status indicator and unified chat widget."), - ], - markdownDescription: nls.localize('chat.agentsControl.enabled', "Controls how the 'Agent Status' indicator appears in the title bar command center. When set to `hidden`, the indicator is not shown. Other values show the indicator and automatically enable {0}. The unread and in-progress session indicators require {1} to be enabled.", '`#window.commandCenter#`', '`#chat.viewSessions.enabled#`'), - default: 'compact', - tags: ['experimental'] - }, - [ChatConfiguration.UnifiedAgentsBar]: { - type: 'boolean', - markdownDescription: nls.localize('chat.unifiedAgentsBar.enabled', "Replaces the command center search box with a unified chat and search widget."), - default: false, - tags: ['experimental'] - }, - [ChatConfiguration.AgentSessionProjectionEnabled]: { - type: 'boolean', - markdownDescription: nls.localize('chat.agentSessionProjection.enabled', "Controls whether Agent Session Projection mode is enabled for reviewing agent sessions in a focused workspace."), - default: false, - tags: ['experimental'], - }, - 'chat.implicitContext.enabled': { - type: 'object', - description: nls.localize('chat.implicitContext.enabled.1', "Enables automatically using the active editor as chat context for specified chat locations."), - additionalProperties: { - type: 'string', - enum: ['never', 'first', 'always'], - description: nls.localize('chat.implicitContext.value', "The value for the implicit context."), - enumDescriptions: [ - nls.localize('chat.implicitContext.value.never', "Implicit context is never enabled."), - nls.localize('chat.implicitContext.value.first', "Implicit context is enabled for the first interaction."), - nls.localize('chat.implicitContext.value.always', "Implicit context is always enabled.") - ] - }, - default: { - 'panel': 'always', - }, - tags: ['experimental'], - experiment: { - mode: 'startup' - }, - agentsWindow: { default: { 'panel': 'never' } }, - }, - 'chat.implicitContext.suggestedContext': { - type: 'boolean', - markdownDescription: nls.localize('chat.implicitContext.suggestedContext', "Controls whether the new implicit context flow is shown. In Ask and Edit modes, the context will automatically be included. When using an agent, context will be suggested as an attachment. Selections are always included as context."), - default: true, - agentsWindow: { default: false }, - }, - 'chat.editing.autoAcceptDelay': { - type: 'number', - markdownDescription: nls.localize('chat.editing.autoAcceptDelay', "Delay after which changes made by chat are automatically accepted. Values are in seconds, `0` means disabled and `100` seconds is the maximum."), - default: 0, - minimum: 0, - maximum: 100 - }, - 'chat.editing.confirmEditRequestRemoval': { - type: 'boolean', - scope: ConfigurationScope.APPLICATION, - markdownDescription: nls.localize('chat.editing.confirmEditRequestRemoval', "Whether to show a confirmation before removing a request and its associated edits."), - default: true, - }, - 'chat.editing.confirmEditRequestRetry': { - type: 'boolean', - scope: ConfigurationScope.APPLICATION, - markdownDescription: nls.localize('chat.editing.confirmEditRequestRetry', "Whether to show a confirmation before retrying a request and its associated edits."), - default: true, - }, - 'chat.editing.explainChanges.enabled': { - type: 'boolean', - markdownDescription: nls.localize('chat.editing.explainChanges.enabled', "Controls whether the Explain button in the Chat panel and the Explain Changes context menu in the SCM view are shown. This is an experimental feature."), - default: false, - tags: ['experimental'], - experiment: { - mode: 'auto' - } - }, - [ChatConfiguration.RevealNextChangeOnResolve]: { - type: 'boolean', - markdownDescription: nls.localize('chat.editing.revealNextChangeOnResolve', "Controls whether the editor automatically reveals the next change after keeping or undoing a chat edit."), - default: true, - }, - 'chat.tips.enabled': { - type: 'boolean', - scope: ConfigurationScope.APPLICATION, - description: nls.localize('chat.tips.enabled', "Controls whether tips are shown above user messages in chat. New tips are added frequently, so this is a helpful way to stay up to date with the latest features."), - default: true, - }, - 'chat.upvoteAnimation': { - type: 'string', - enum: ['off', 'confetti', 'floatingThumbs', 'pulseWave', 'radiantLines'], - enumDescriptions: [ - nls.localize('chat.upvoteAnimation.off', "No animation is shown."), - nls.localize('chat.upvoteAnimation.confetti', "Shows a confetti burst animation around the thumbs up button."), - nls.localize('chat.upvoteAnimation.floatingThumbs', "Shows floating thumbs up icons rising from the button."), - nls.localize('chat.upvoteAnimation.pulseWave', "Shows expanding pulse rings from the button."), - nls.localize('chat.upvoteAnimation.radiantLines', "Shows radiant lines emanating from the button."), - ], - description: nls.localize('chat.upvoteAnimation', "Controls whether an animation is shown when clicking the thumbs up button on a chat response."), - default: 'floatingThumbs', - }, - 'chat.experimental.detectParticipant.enabled': { - type: 'boolean', - deprecationMessage: nls.localize('chat.experimental.detectParticipant.enabled.deprecated', "This setting is deprecated. Please use `chat.detectParticipant.enabled` instead."), - description: nls.localize('chat.experimental.detectParticipant.enabled', "Enables chat participant autodetection for panel chat."), - default: null - }, - [ChatConfiguration.IncrementalRendering]: { - type: 'boolean', - description: nls.localize('chat.experimental.incrementalRendering.enabled', "Enables incremental rendering with optional block-level animation when streaming chat responses."), - default: false, - tags: ['experimental'], - }, - [ChatConfiguration.IncrementalRenderingStyle]: { - type: 'string', - enum: ['none', 'fade', 'rise', 'blur', 'scale', 'slide', 'reveal'], - enumDescriptions: [ - nls.localize('chat.experimental.incrementalRendering.animationStyle.none', "No animation. Content appears instantly."), - nls.localize('chat.experimental.incrementalRendering.animationStyle.fade', "Simple opacity fade from 0 to 1."), - nls.localize('chat.experimental.incrementalRendering.animationStyle.rise', "Content fades in while rising upward."), - nls.localize('chat.experimental.incrementalRendering.animationStyle.blur', "Content fades in from a blurred state."), - nls.localize('chat.experimental.incrementalRendering.animationStyle.scale', "Content scales up from slightly smaller."), - nls.localize('chat.experimental.incrementalRendering.animationStyle.slide', "Content slides in from the left."), - nls.localize('chat.experimental.incrementalRendering.animationStyle.reveal', "Content reveals top-to-bottom with a soft gradient edge."), - ], - description: nls.localize('chat.experimental.incrementalRendering.animationStyle', "Controls the animation style for incremental rendering."), - default: 'fade', - tags: ['experimental'], - }, - [ChatConfiguration.IncrementalRenderingBuffering]: { - type: 'string', - enum: ['off', 'word', 'paragraph'], - enumDescriptions: [ - nls.localize('chat.experimental.incrementalRendering.buffering.off', "Renders content immediately as tokens arrive."), - nls.localize('chat.experimental.incrementalRendering.buffering.word', "Reveals content word by word."), - nls.localize('chat.experimental.incrementalRendering.buffering.paragraph', "Buffers content until a paragraph break before rendering."), - ], - description: nls.localize('chat.experimental.incrementalRendering.buffering', "Controls how content is buffered before rendering during incremental rendering. Lower buffering levels render faster but may show incomplete sentences or partially formed markdown."), - default: 'word', - tags: ['experimental'], - }, - 'chat.detectParticipant.enabled': { - type: 'boolean', - description: nls.localize('chat.detectParticipant.enabled', "Enables chat participant autodetection for panel chat."), - default: true - }, - [ChatConfiguration.InlineReferencesStyle]: { - type: 'string', - enum: ['box', 'link'], - enumDescriptions: [ - nls.localize('chat.inlineReferences.style.box', "Display file and symbol references as boxed widgets with icons."), - nls.localize('chat.inlineReferences.style.link', "Display file and symbol references as simple blue links without icons.") - ], - description: nls.localize('chat.inlineReferences.style', "Controls how file and symbol references are displayed in chat messages."), - default: 'box' - }, - [ChatConfiguration.EditorAssociations]: { - type: 'object', - markdownDescription: nls.localize('chat.editorAssociations', "Configure [glob patterns](https://aka.ms/vscode-glob-patterns) to editors for opening files from chat (for example `\"*.md\": \"vscode.markdown.preview.editor\"`)."), - additionalProperties: { - type: 'string' - }, - default: { - } - }, - [ChatConfiguration.NotifyWindowOnConfirmation]: { - type: 'string', - enum: ['off', 'windowNotFocused', 'always'], - enumDescriptions: [ - nls.localize('chat.notifyWindowOnConfirmation.off', "Never show OS notifications for confirmations."), - nls.localize('chat.notifyWindowOnConfirmation.windowNotFocused', "Show OS notifications for confirmations when the window is not focused."), - nls.localize('chat.notifyWindowOnConfirmation.always', "Always show OS notifications for confirmations, even when the window is focused."), - ], - description: nls.localize('chat.notifyWindowOnConfirmation', "Controls whether a chat session should present the user with an OS notification when a confirmation or question needs input. This includes a window badge as well as notification toast."), - default: 'windowNotFocused', - }, - [ChatConfiguration.AutoReply]: { - default: false, - markdownDescription: nls.localize('chat.autoReply.description', "Automatically skip question carousels by telling the agent that the user is not available and to use its best judgment. This is an advanced setting and can lead to unintended choices or actions based on incomplete context."), - type: 'boolean', - scope: ConfigurationScope.APPLICATION_MACHINE, - tags: ['experimental', 'advanced'], - }, - [ChatConfiguration.AutopilotEnabled]: { - type: 'boolean', - markdownDescription: nls.localize('chat.autopilot.enabled', "Controls whether the Autopilot mode is available in the permissions picker. When enabled, Autopilot auto-approves all tool calls and continues until the task is done."), - default: true, - tags: ['experimental'], - }, - [ChatConfiguration.PlanReviewInlineEditorEnabled]: { - type: 'boolean', - markdownDescription: nls.localize('chat.planReview.inlineEditor.enabled', "When enabled, the plan review widget mounts an editor inline, as opposed to in a separate editor tab."), - default: true, - }, - [ChatConfiguration.DefaultPermissionLevel]: { - type: 'string', - enum: [ChatPermissionLevel.Default, ChatPermissionLevel.AutoApprove, ChatPermissionLevel.Autopilot], - enumItemLabels: [ - nls.localize('chat.permissions.default.default.label', "Default Approvals"), - nls.localize('chat.permissions.default.autoApprove.label', "Bypass Approvals"), - nls.localize('chat.permissions.default.autopilot.label', "Autopilot (Preview)"), - ], - enumDescriptions: [ - nls.localize('chat.permissions.default.default.description', "Start new chat sessions with Default Approvals."), - nls.localize('chat.permissions.default.autoApprove.description', "Start new chat sessions in Bypass Approvals mode."), - nls.localize('chat.permissions.default.autopilot.description', "Start new chat sessions in Autopilot mode."), - ], - description: nls.localize('chat.permissions.default.settingDescription', "Controls the default permissions picker mode for new chat sessions. You can still change the permission mode per session, and each session remembers the permission mode that was used. If enterprise policy disables auto approval, new sessions use Default Approvals."), - default: ChatPermissionLevel.Default, - tags: ['experimental'], - }, - [ChatConfiguration.GlobalAutoApprove]: { - default: false, - markdownDescription: globalAutoApproveDescription.value, - type: 'boolean', - scope: ConfigurationScope.APPLICATION_MACHINE, - tags: ['experimental'], - policy: { - name: 'ChatToolsAutoApprove', - category: PolicyCategory.InteractiveSession, - minimumVersion: '1.99', - value: (policyData) => policyData.chat_preview_features_enabled === false ? false : undefined, - localization: { - description: { - key: 'autoApprove3.description', - value: nls.localize('autoApprove3.description', 'Global auto approve also known as "YOLO mode" disables manual approval completely for all tools in all workspaces, allowing the agent to act fully autonomously. This is extremely dangerous and is *never* recommended, even containerized environments like Codespaces and Dev Containers have user keys forwarded into the container that could be compromised.\n\nThis feature disables critical security protections and makes it much easier for an attacker to compromise the machine.\n\nNote: This setting only controls tool approval and does not prevent the agent from asking questions. To automatically answer agent questions, use the `#chat.autoReply#` setting.') - } - }, - } - }, - [ChatConfiguration.SessionSyncEnabled]: { - default: false, - markdownDescription: nls.localize('chat.sessionSync.enabled', "Enable session sync to GitHub.com. When enabled, Copilot session data is synced to your GitHub account for cross-device access and richer insights. Requires `#github.copilot.chat.localIndex.enabled#` to also be enabled."), - type: 'boolean', - tags: ['experimental', 'advanced'], - experiment: { - mode: 'auto' - }, - policy: { - name: 'CopilotSessionSync', - category: PolicyCategory.InteractiveSession, - minimumVersion: '1.121', - value: (policyData) => policyData.cloud_session_storage_enabled === false ? false : undefined, - localization: { - description: { - key: 'chat.sessionSync.enabled.policy', - value: nls.localize('chat.sessionSync.enabled.policy', "Enable session sync to GitHub.com for cross-device Copilot session history. When disabled by organization policy, session data is kept local only."), - } - }, - } - }, - [ChatConfiguration.SessionSyncExcludeRepositories]: { - type: 'array', - items: { type: 'string' }, - default: [], - markdownDescription: nls.localize('chat.sessionSync.excludeRepositories', "Repository patterns to exclude from session sync. Use exact `owner/repo` names or glob patterns like `my-org/*`. Sessions from matching repositories will only be stored locally."), - tags: ['experimental', 'advanced'], - }, - [ChatConfiguration.AutoApproveEdits]: { - default: { - '**/*': 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, // yarn.lock, bun.lock, etc. - '**/*-lock.{yaml,json}': false, // pnpm-lock.yaml, package-lock.json - }, - markdownDescription: nls.localize('chat.tools.autoApprove.edits', "Controls whether edits made by the agent are automatically approved. The default is to approve all edits except those made to certain files which have the potential to cause immediate unintended side-effects, such as `**/.vscode/*.json`.\n\nSet to `true` to automatically approve edits to matching files, `false` to always require explicit approval. The last pattern matching a given file will determine whether the edit is automatically approved."), - type: 'object', - additionalProperties: { - type: 'boolean', - } - }, - [ChatConfiguration.AutoApprovedUrls]: { - default: { - 'https://code.visualstudio.com': true, - 'https://github.com/microsoft/vscode/wiki/*': true, - }, - markdownDescription: nls.localize('chat.tools.fetchPage.approvedUrls', "Controls which URLs are automatically approved when requested by chat tools. Keys are URL patterns and values can be `true` to approve both requests and responses, `false` to deny, or an object with `approveRequest` and `approveResponse` properties for granular control.\n\nExamples:\n- `\"https://example.com\": true` - Approve all requests to example.com\n- `\"https://*.example.com\": true` - Approve all requests to any subdomain of example.com\n- `\"https://example.com/api/*\": { \"approveRequest\": true, \"approveResponse\": false }` - Approve requests but not responses for example.com/api paths"), - type: 'object', - additionalProperties: { - oneOf: [ - { type: 'boolean' }, - { - type: 'object', - properties: { - approveRequest: { type: 'boolean' }, - approveResponse: { type: 'boolean' } - } - } - ] - } - }, - [ChatConfiguration.EligibleForAutoApproval]: { - default: {}, - markdownDescription: nls.localize('chat.tools.eligibleForAutoApproval', 'Controls which tools are eligible for automatic approval. Tools set to \'false\' will always present a confirmation and will never offer the option to auto-approve. The default behavior (or setting a tool to \'true\') may result in the tool offering auto-approval options.'), - type: 'object', - propertyNames: { - enum: toolReferenceNameEnumValues, - enumDescriptions: toolReferenceNameEnumDescriptions, - }, - additionalProperties: { - type: 'boolean', - }, - examples: [ - { - 'fetch': false, - 'runTask': false - } - ], - policy: { - name: 'ChatToolsEligibleForAutoApproval', - category: PolicyCategory.InteractiveSession, - minimumVersion: '1.107', - localization: { - description: { - key: 'chat.tools.eligibleForAutoApproval', - value: nls.localize('chat.tools.eligibleForAutoApproval', 'Controls which tools are eligible for automatic approval. Tools set to \'false\' will always present a confirmation and will never offer the option to auto-approve. The default behavior (or setting a tool to \'true\') may result in the tool offering auto-approval options.') - } - }, - } - }, - 'chat.sendElementsToChat.attachImages': { - default: true, - markdownDescription: nls.localize('chat.sendElementsToChat.attachImages', "Controls whether a screenshot of the selected element will be added to the chat."), - type: 'boolean', - tags: ['experimental'] - }, - [ChatConfiguration.ArtifactsEnabled]: { - default: false, - description: nls.localize('chat.artifacts.enabled', "Controls whether the artifacts view is available in chat."), - type: 'boolean', - tags: ['experimental'] - }, - [ChatConfiguration.ArtifactsRulesByMimeType]: { - default: { - 'image/*': { groupName: 'Screenshots', onlyShowGroup: true } - }, - description: nls.localize('chat.artifacts.rules.byMimeType', "Rules for extracting artifacts from tool results by MIME type. Maps MIME type patterns (e.g. 'image/*') to group configuration."), - type: 'object', - additionalProperties: { - type: 'object', - properties: { - groupName: { type: 'string', description: nls.localize('chat.artifacts.rules.groupName', "Display name for the artifact group.") }, - onlyShowGroup: { type: 'boolean', description: nls.localize('chat.artifacts.rules.onlyShowGroup', "When true, show only the group header instead of individual items.") } - }, - required: ['groupName'] - }, - tags: ['experimental'] - }, - [ChatConfiguration.ArtifactsRulesByFilePath]: { - default: { - '**/*plan*.md': { groupName: 'Plans' } - }, - description: nls.localize('chat.artifacts.rules.byFilePath', "Rules for extracting artifacts from written files by file path pattern. Maps glob patterns to group configuration."), - type: 'object', - additionalProperties: { - type: 'object', - properties: { - groupName: { type: 'string', description: nls.localize('chat.artifacts.rules.byFilePath.groupName', "Display name for the artifact group.") }, - onlyShowGroup: { type: 'boolean', description: nls.localize('chat.artifacts.rules.byFilePath.onlyShowGroup', "When true, show only the group header instead of individual items.") } - }, - required: ['groupName'] - }, - tags: ['experimental'] - }, - [ChatConfiguration.ArtifactsRulesByMemoryFilePath]: { - default: { - '**/*plan*.md': { groupName: 'Plans' } - }, - description: nls.localize('chat.artifacts.rules.byMemoryFilePath', "Rules for extracting artifacts from memory tool calls by memory file path pattern. Maps glob patterns to group configuration."), - type: 'object', - additionalProperties: { - type: 'object', - properties: { - groupName: { type: 'string', description: nls.localize('chat.artifacts.rules.byMemoryFilePath.groupName', "Display name for the artifact group.") }, - onlyShowGroup: { type: 'boolean', description: nls.localize('chat.artifacts.rules.byMemoryFilePath.onlyShowGroup', "When true, show only the group header instead of individual items.") } - }, - required: ['groupName'] - }, - tags: ['experimental'] - }, - 'chat.undoRequests.restoreInput': { - default: true, - markdownDescription: nls.localize('chat.undoRequests.restoreInput', "Controls whether the input of the chat should be restored when an undo request is made. The input will be filled with the text of the request that was restored."), - type: 'boolean', - }, - 'chat.editRequests': { - markdownDescription: nls.localize('chat.editRequests', "Enables editing of requests in the chat. This allows you to change the request content and resubmit it to the model."), - type: 'string', - enum: ['inline', 'hover', 'input', 'none'], - default: 'inline', - }, - [ChatConfiguration.ChatViewSessionsEnabled]: { - type: 'boolean', - default: true, - description: nls.localize('chat.viewSessions.enabled', "Show chat agent sessions when chat is empty or to the side when chat view is wide enough."), - agentsWindow: { default: false }, - }, - [ChatConfiguration.ChatViewSessionsOrientation]: { - type: 'string', - enum: ['stacked', 'sideBySide'], - enumDescriptions: [ - nls.localize('chat.viewSessions.orientation.stacked', "Display chat sessions vertically stacked above the chat input unless a chat session is visible."), - nls.localize('chat.viewSessions.orientation.sideBySide', "Display chat sessions side by side if space is sufficient, otherwise fallback to stacked above the chat input unless a chat session is visible.") - ], - default: 'sideBySide', - description: nls.localize('chat.viewSessions.orientation', "Controls the orientation of the chat agent sessions view when it is shown alongside the chat."), - }, - [ChatConfiguration.ChatViewProgressBadgeEnabled]: { - type: 'boolean', - default: false, - description: nls.localize('chat.viewProgressBadge.enabled', "Show a progress badge on the chat view when an agent session is in progress that is opened in that view."), - }, - [ChatConfiguration.ChatContextUsageEnabled]: { - type: 'boolean', - default: true, - description: nls.localize('chat.contextUsage.enabled', "Show the context window usage indicator in the chat input."), - }, - [ChatConfiguration.ChatPersistentProgressEnabled]: { - type: 'boolean', - default: product.quality !== 'stable', - description: nls.localize('chat.persistentProgress.enabled', "Always show progress in chat."), - }, - [ChatConfiguration.ProgressBorder]: { - type: 'boolean', - default: true, - markdownDescription: nls.localize('chat.progressBorder.enabled', "Show an animated gradient border around the chat input while the agent is working or thinking. When enabled and reduced motion is not enabled, this overrides {0} to be off. Has no effect when reduced motion is enabled.", '`#chat.persistentProgress.enabled#`'), - }, - [ChatConfiguration.NotifyWindowOnResponseReceived]: { - type: 'string', - enum: ['off', 'windowNotFocused', 'always'], - enumDescriptions: [ - nls.localize('chat.notifyWindowOnResponseReceived.off', "Never show OS notifications for responses."), - nls.localize('chat.notifyWindowOnResponseReceived.windowNotFocused', "Show OS notifications for responses when the window is not focused."), - nls.localize('chat.notifyWindowOnResponseReceived.always', "Always show OS notifications for responses, even when the window is focused."), - ], - default: 'windowNotFocused', - description: nls.localize('chat.notifyWindowOnResponseReceived', "Controls whether a chat session should present the user with an OS notification when a response is received. This includes a window badge as well as notification toast."), - }, - 'chat.checkpoints.enabled': { - type: 'boolean', - default: true, - description: nls.localize('chat.checkpoints.enabled', "Enables checkpoints in chat. Checkpoints allow you to restore the chat to a previous state."), - }, - 'chat.checkpoints.showFileChanges': { - type: 'boolean', - description: nls.localize('chat.checkpoints.showFileChanges', "Controls whether to show chat checkpoint file changes."), - default: false - }, - [mcpAccessConfig]: { - type: 'string', - description: nls.localize('chat.mcp.access', "Controls access to installed Model Context Protocol servers."), - enum: [ - McpAccessValue.None, - McpAccessValue.Registry, - McpAccessValue.All - ], - enumDescriptions: [ - nls.localize('chat.mcp.access.none', "No access to MCP servers."), - nls.localize('chat.mcp.access.registry', "Allows access to MCP servers installed from the registry that VS Code is connected to."), - nls.localize('chat.mcp.access.any', "Allow access to any installed MCP server.") - ], - default: McpAccessValue.All, - policy: { - name: 'ChatMCP', - category: PolicyCategory.InteractiveSession, - minimumVersion: '1.99', - value: (policyData) => { - if (policyData.mcp === false) { - return McpAccessValue.None; - } - if (policyData.mcpAccess === 'registry_only') { - return McpAccessValue.Registry; - } - return undefined; - }, - localization: { - description: { - key: 'chat.mcp.access', - value: nls.localize('chat.mcp.access', "Controls access to installed Model Context Protocol servers.") - }, - enumDescriptions: [ - { - key: 'chat.mcp.access.none', value: nls.localize('chat.mcp.access.none', "No access to MCP servers."), - }, - { - key: 'chat.mcp.access.registry', value: nls.localize('chat.mcp.access.registry', "Allows access to MCP servers installed from the registry that VS Code is connected to."), - }, - { - key: 'chat.mcp.access.any', value: nls.localize('chat.mcp.access.any', "Allow access to any installed MCP server.") - } - ] - }, - } - }, - [mcpAutoStartConfig]: { - type: 'string', - description: nls.localize('chat.mcp.autostart', "Controls whether MCP servers should be automatically started when the chat messages are submitted."), - default: McpAutoStartValue.NewAndOutdated, - enum: [ - McpAutoStartValue.Never, - McpAutoStartValue.OnlyNew, - McpAutoStartValue.NewAndOutdated - ], - enumDescriptions: [ - nls.localize('chat.mcp.autostart.never', "Never automatically start MCP servers."), - nls.localize('chat.mcp.autostart.onlyNew', "Only automatically start new MCP servers that have never been run."), - nls.localize('chat.mcp.autostart.newAndOutdated', "Automatically start new and outdated MCP servers that are not yet running.") - ], - tags: ['experimental'], - }, - [mcpAppsEnabledConfig]: { - type: 'boolean', - description: nls.localize('chat.mcp.ui.enabled', "Controls whether MCP servers can provide custom UI for tool invocations."), - default: true, - tags: ['experimental'], - }, - [mcpServerCollisionBehaviorSection]: { - type: 'string', - description: nls.localize('chat.mcp.collisionBehavior', "Controls behavior when multiple MCP servers are discovered with the same name. 'disable' disables lower-priority duplicates. 'suffix' appends numeric suffixes to disambiguate."), - enum: [ - McpCollisionBehavior.Disable, - McpCollisionBehavior.Suffix, - ], - enumDescriptions: [ - nls.localize('chat.mcp.collisionBehavior.disable', "Disable lower-priority servers with duplicate names."), - nls.localize('chat.mcp.collisionBehavior.suffix', "Append numeric suffixes to servers with duplicate names."), - ], - default: McpCollisionBehavior.Disable, - }, - [mcpServerSamplingSection]: { - type: 'object', - description: nls.localize('chat.mcp.serverSampling', "Configures which models are exposed to MCP servers for sampling (making model requests in the background). This setting can be edited in a graphical way under the `{0}` command.", 'MCP: ' + nls.localize('mcp.list', 'List Servers')), - scope: ConfigurationScope.RESOURCE, - additionalProperties: { - type: 'object', - properties: { - allowedDuringChat: { - type: 'boolean', - description: nls.localize('chat.mcp.serverSampling.allowedDuringChat', "Whether this server is allowed to make sampling requests during its tool calls in a chat session."), - default: true, - }, - allowedOutsideChat: { - type: 'boolean', - description: nls.localize('chat.mcp.serverSampling.allowedOutsideChat', "Whether this server is allowed to make sampling requests outside of a chat session."), - default: false, - }, - allowedModels: { - type: 'array', - items: { - type: 'string', - description: nls.localize('chat.mcp.serverSampling.model', "A model the MCP server has access to."), - }, - } - } - }, - }, - [AssistedTypes[AddConfigurationType.NuGetPackage].enabledConfigKey]: { - type: 'boolean', - description: nls.localize('chat.mcp.assisted.nuget.enabled.description', "Enables NuGet packages for AI-assisted MCP server installation. Used to install MCP servers by name from the central registry for .NET packages (NuGet.org)."), - default: false, - tags: ['experimental'], - experiment: { - mode: 'startup' - } - }, - [ChatConfiguration.ExtensionToolsEnabled]: { - type: 'boolean', - description: nls.localize('chat.extensionToolsEnabled', "Enable using tools contributed by third-party extensions."), - default: true, - policy: { - name: 'ChatAgentExtensionTools', - category: PolicyCategory.InteractiveSession, - minimumVersion: '1.99', - localization: { - description: { - key: 'chat.extensionToolsEnabled', - value: nls.localize('chat.extensionToolsEnabled', "Enable using tools contributed by third-party extensions.") - } - }, - } - }, - [ChatConfiguration.PluginsEnabled]: { - type: 'boolean', - description: nls.localize('chat.plugins.enabled', "Enable agent plugin integration in chat."), - default: true, - tags: ['preview'], - policy: { - name: 'ChatPluginsEnabled', - category: PolicyCategory.InteractiveSession, - minimumVersion: '1.116', - localization: { - description: { - key: 'chat.plugins.enabled', - value: nls.localize('chat.plugins.enabled', "Enable agent plugin integration in chat."), - } - }, - }, - }, - [ChatConfiguration.PluginLocations]: { - type: 'object', - additionalProperties: { type: 'boolean' }, - restricted: true, - markdownDescription: nls.localize('chat.pluginLocations', "Plugin directories to discover. Each key is a path that points directly to a plugin folder, and the value enables (`true`) or disables (`false`) it. Paths can be absolute, relative to the workspace root, or start with `~/` for the user's home directory."), - scope: ConfigurationScope.MACHINE, - tags: ['experimental'], - }, - [ChatConfiguration.PluginMarketplaces]: { - type: 'array', - items: { - type: 'string', - }, - markdownDescription: nls.localize('chat.plugins.marketplaces', "Plugin marketplaces to query. Entries may be GitHub shorthand (`owner/repo`), direct Git repository URIs (`https://...git`, `ssh://...git`, or `git@host:path.git`), or local repository URIs (`file:///...`). Equivalent GitHub shorthand and URI entries are deduplicated."), - default: ['github/copilot-plugins', 'github/awesome-copilot'], - scope: ConfigurationScope.APPLICATION, - tags: ['experimental'], - }, - [ChatConfiguration.AgentEnabled]: { - type: 'boolean', - description: nls.localize('chat.agent.enabled.description', "When enabled, agent mode can be activated from chat and tools in agentic contexts with side effects can be used."), - default: true, - order: 1, - policy: { - name: 'ChatAgentMode', - category: PolicyCategory.InteractiveSession, - minimumVersion: '1.99', - value: (policyData) => policyData.chat_agent_enabled === false ? false : undefined, - localization: { - description: { - key: 'chat.agent.enabled.description', - value: nls.localize('chat.agent.enabled.description', "When enabled, agent mode can be activated from chat and tools in agentic contexts with side effects can be used."), - } - } - } - }, - [AgentNetworkDomainSettingId.NetworkFilter]: { - markdownDescription: nls.localize('chat.agent.networkFilter', "When enabled, network access by agent tools (fetch tool, integrated browser) is restricted according to {0} and {1}. Domain filtering is also applied to those tools when {2} is enabled.", `\`#${AgentNetworkDomainSettingId.AllowedNetworkDomains}#\``, `\`#${AgentNetworkDomainSettingId.DeniedNetworkDomains}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``), - type: 'boolean', - default: false, - restricted: true, - policy: { - name: 'ChatAgentNetworkFilter', - category: PolicyCategory.InteractiveSession, - minimumVersion: '1.116', - localization: { - description: { - key: 'chat.agent.networkFilter', - value: nls.localize('chat.agent.networkFilter', "When enabled, network access by agent tools (fetch tool, integrated browser) is restricted according to {0} and {1}. Domain filtering is also applied to those tools when {2} is enabled.", `\`#${AgentNetworkDomainSettingId.AllowedNetworkDomains}#\``, `\`#${AgentNetworkDomainSettingId.DeniedNetworkDomains}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``), - } - } - } - }, - [AgentNetworkDomainSettingId.AllowedNetworkDomains]: { - markdownDescription: nls.localize('chat.agent.allowedNetworkDomains', "Allowed domains for network access by agent tools (fetch tool, integrated browser). Applies when {0} or {1} is enabled. When {1} is set to {2}, all domains are allowed. Supports wildcards like {3}. When both allowed and denied lists are empty, all domains are blocked. Denied domains (see {4}) take precedence.", `\`#${AgentNetworkDomainSettingId.NetworkFilter}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``, `\`${AgentSandboxEnabledValue.AllowNetwork}\``, '`*.example.com`', `\`#${AgentNetworkDomainSettingId.DeniedNetworkDomains}#\``), - type: 'array', - items: { type: 'string' }, - default: [], - restricted: true, - policy: { - name: 'ChatAgentAllowedNetworkDomains', - category: PolicyCategory.InteractiveSession, - minimumVersion: '1.116', - localization: { - description: { - key: 'chat.agent.allowedNetworkDomains', - value: nls.localize('chat.agent.allowedNetworkDomains', "Allowed domains for network access by agent tools (fetch tool, integrated browser). Applies when {0} or {1} is enabled. When {1} is set to {2}, all domains are allowed. Supports wildcards like {3}. When both allowed and denied lists are empty, all domains are blocked. Denied domains (see {4}) take precedence.", `\`#${AgentNetworkDomainSettingId.NetworkFilter}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``, `\`${AgentSandboxEnabledValue.AllowNetwork}\``, '`*.example.com`', `\`#${AgentNetworkDomainSettingId.DeniedNetworkDomains}#\``), - } - } - } - }, - [AgentNetworkDomainSettingId.DeniedNetworkDomains]: { - markdownDescription: nls.localize('chat.agent.deniedNetworkDomains', "Denied domains for network access by agent tools (fetch tool, integrated browser). Applies when {0} or {1} is enabled. This does not apply when {1} is set to {2}. Takes precedence over {3}. Supports wildcards like {4}.", `\`#${AgentNetworkDomainSettingId.NetworkFilter}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``, `\`${AgentSandboxEnabledValue.AllowNetwork}\``, `\`#${AgentNetworkDomainSettingId.AllowedNetworkDomains}#\``, '`*.example.com`'), - type: 'array', - items: { type: 'string' }, - default: [], - restricted: true, - policy: { - name: 'ChatAgentDeniedNetworkDomains', - category: PolicyCategory.InteractiveSession, - minimumVersion: '1.116', - localization: { - description: { - key: 'chat.agent.deniedNetworkDomains', - value: nls.localize('chat.agent.deniedNetworkDomains', "Denied domains for network access by agent tools (fetch tool, integrated browser). Applies when {0} or {1} is enabled. This does not apply when {1} is set to {2}. Takes precedence over {3}. Supports wildcards like {4}.", `\`#${AgentNetworkDomainSettingId.NetworkFilter}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``, `\`${AgentSandboxEnabledValue.AllowNetwork}\``, `\`#${AgentNetworkDomainSettingId.AllowedNetworkDomains}#\``, '`*.example.com`'), - } - } - } - }, - [AgentNetworkDomainSettingId.DeprecatedOldAllowedNetworkDomains]: { - type: 'array', - items: { type: 'string' }, - deprecated: true, - markdownDeprecationMessage: nls.localize('agentSandbox.allowedNetworkDomains.deprecated', 'Use {0} instead', `\`#${AgentNetworkDomainSettingId.AllowedNetworkDomains}#\``), - }, - [AgentNetworkDomainSettingId.DeprecatedOldDeniedNetworkDomains]: { - type: 'array', - items: { type: 'string' }, - deprecated: true, - markdownDeprecationMessage: nls.localize('agentSandbox.deniedNetworkDomains.deprecated', 'Use {0} instead', `\`#${AgentNetworkDomainSettingId.DeniedNetworkDomains}#\``), - }, - [AgentNetworkDomainSettingId.DeprecatedSandboxAllowedNetworkDomains]: { - type: 'array', - items: { type: 'string' }, - deprecated: true, - markdownDeprecationMessage: nls.localize('agentSandbox.allowedNetworkDomains2.deprecated', 'Use {0} instead', `\`#${AgentNetworkDomainSettingId.AllowedNetworkDomains}#\``), - }, - [AgentNetworkDomainSettingId.DeprecatedSandboxDeniedNetworkDomains]: { - type: 'array', - items: { type: 'string' }, - deprecated: true, - markdownDeprecationMessage: nls.localize('agentSandbox.deniedNetworkDomains2.deprecated', 'Use {0} instead', `\`#${AgentNetworkDomainSettingId.DeniedNetworkDomains}#\``), - }, - [ChatConfiguration.DefaultNewSessionMode]: { - type: 'string', - description: nls.localize('chat.newSession.defaultMode', "The default mode for new chat sessions. When empty, the chat view's default mode is used."), - default: '', - }, - [AgentHostEnabledSettingId]: { - type: 'boolean', - description: nls.localize('chat.agentHost.enabled', "When enabled, some agents run in a separate agent host process."), - default: false, - tags: ['experimental', 'advanced'], - included: product.quality !== 'stable', - }, - [AgentHostClaudeAgentSdkPathSettingId]: { - type: 'string', - description: nls.localize('chat.agentHost.claudeAgent.path', "Experimental, for local testing only. Absolute path to a locally-installed `@anthropic-ai/claude-agent-sdk` package. When set, the Claude agent provider is registered inside the agent host and the SDK is loaded from this path. Requires `#chat.agentHost.enabled#`. The agent host process must be restarted for changes to take effect. This setting will be removed once the SDK is delivered through the Extension Marketplace."), - default: '', - tags: ['experimental', 'advanced'], - included: product.quality !== 'stable', - }, - [AgentHostIpcLoggingSettingId]: { - type: 'boolean', - description: nls.localize('chat.agentHost.ipcLogging', "When enabled, logs all IPC traffic for each agent host to a dedicated output channel."), - default: product.quality !== 'stable', - tags: ['experimental', 'advanced'], - included: product.quality !== 'stable', - }, - [AgentHostAhpJsonlLoggingSettingId]: { - type: 'boolean', - description: nls.localize('chat.agentHost.ahpJsonlLogging', "When enabled, logs all AHP transport messages for agent host connections to JSONL files under the window's log directory."), - default: product.quality !== 'stable', - tags: ['experimental', 'advanced'], - included: product.quality !== 'stable', - }, - [AgentHostCustomTerminalToolEnabledSettingId]: { - type: 'boolean', - description: nls.localize('chat.agentHost.customTerminalTool.enabled', "When enabled, Copilot SDK sessions use the Agent Host terminal tool override instead of the SDK's default terminal behavior."), - default: true, - tags: ['experimental', 'advanced'], - included: product.quality !== 'stable', - }, - [AgentHostOTelEnabledSettingId]: { - type: 'boolean', - description: nls.localize('chat.agentHost.otel.enabled', "When enabled, the agent host emits OpenTelemetry traces from the Copilot SDK. Requires `#chat.agentHost.enabled#`. Either configure `#chat.agentHost.otel.otlpEndpoint#` to ship traces to an external collector or enable `#chat.agentHost.otel.dbSpanExporter.enabled#` to capture them locally."), - default: false, - tags: ['experimental', 'advanced'], - included: product.quality !== 'stable', - }, - [AgentHostOTelExporterTypeSettingId]: { - type: 'string', - enum: ['otlp-http', 'otlp-grpc', 'console', 'file'], - description: nls.localize('chat.agentHost.otel.exporterType', "Exporter backend used by the Copilot SDK when `#chat.agentHost.otel.enabled#` is on. `otlp-grpc` is downgraded to `otlp-http` transparently in the CLI runtime."), - default: 'otlp-http', - tags: ['experimental', 'advanced'], - included: product.quality !== 'stable', - }, - [AgentHostOTelOtlpEndpointSettingId]: { - type: 'string', - description: nls.localize('chat.agentHost.otel.otlpEndpoint', "OTLP endpoint URL when exporter type is `otlp-http` or `otlp-grpc`. Sets `OTEL_EXPORTER_OTLP_ENDPOINT` inside the agent host process."), - default: '', - tags: ['experimental', 'advanced'], - included: product.quality !== 'stable', - }, - [AgentHostOTelCaptureContentSettingId]: { - type: 'boolean', - description: nls.localize('chat.agentHost.otel.captureContent', "When enabled, includes prompt and response content in OTel span attributes. Sets `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`. Privacy-sensitive: do not enable in environments that ship spans to shared sinks."), - default: false, - tags: ['experimental', 'advanced'], - included: product.quality !== 'stable', - }, - [AgentHostOTelOutfileSettingId]: { - type: 'string', - description: nls.localize('chat.agentHost.otel.outfile', "Output path for span JSON lines when exporter type is `file`. Sets `COPILOT_OTEL_FILE_EXPORTER_PATH`."), - default: '', - tags: ['experimental', 'advanced'], - included: product.quality !== 'stable', - }, - [AgentHostOTelDbSpanExporterEnabledSettingId]: { - type: 'boolean', - description: nls.localize('chat.agentHost.otel.dbSpanExporter.enabled', "When enabled, the agent host persists every emitted OTel span to a local SQLite database. Spans can be inspected via the `Export Agent Host Traces Database` command. Compatible with external exporters: spans are written to SQLite *and* forwarded to the user-configured sink."), - default: false, - tags: ['experimental', 'advanced'], - included: product.quality !== 'stable', - }, - [ChatConfiguration.AgentHostClientTools]: { - type: 'array', - items: { type: 'string' }, - description: nls.localize('chat.agentHost.clientTools', "Tool reference names to expose as client-provided tools in agent host sessions."), - default: [ - 'runTask', 'getTaskOutput', 'problems', 'runTests', - // These are always present in the {@link ChatConfiguration.AgentHostClientTools} default but - // out of these, only the tools that are actually registered/enabled are seen by the agent. - ...browserChatToolReferenceNames, - ], - tags: ['experimental', 'advanced'], - included: product.quality !== 'stable', - }, - [ChatConfiguration.ToolConfirmationCarousel]: { - type: 'boolean', - description: nls.localize('chat.tools.confirmationCarousel', "When enabled, multiple tool confirmations are batched into a carousel above the input."), - default: product.quality !== 'stable', - tags: ['experimental'], - }, - [ChatConfiguration.ToolRiskAssessmentEnabled]: { - type: 'boolean', - description: nls.localize('chat.tools.riskAssessment.enabled', "When enabled, terminal tool confirmations show an LLM-generated risk level (Safe / Caution / Review carefully) and a short explanation."), - default: true, - experiment: { - mode: 'auto' - }, - }, - [ChatConfiguration.ToolRiskAssessmentModel]: { - type: 'string', - description: nls.localize('chat.tools.riskAssessment.model', "The language model id used to generate tool risk assessments. Should be a small, fast model."), - default: 'copilot-utility-small', - tags: ['experimental', 'advanced'], - experiment: { - mode: 'auto' - }, - }, - [ChatConfiguration.PlanAgentDefaultModel]: { - type: 'string', - description: nls.localize('chat.planAgent.defaultModel.description', "Select the default language model to use for the Plan agent from the available providers."), - default: '', - enum: PlanAgentDefaultModel.modelIds, - enumItemLabels: PlanAgentDefaultModel.modelLabels, - markdownEnumDescriptions: PlanAgentDefaultModel.modelDescriptions - }, - [ChatConfiguration.ExploreAgentDefaultModel]: { - type: 'string', - description: nls.localize('chat.exploreAgent.defaultModel.description', "Select the default language model to use for the Explore subagent from the available providers."), - default: '', - enum: ExploreAgentDefaultModel.modelIds, - enumItemLabels: ExploreAgentDefaultModel.modelLabels, - markdownEnumDescriptions: ExploreAgentDefaultModel.modelDescriptions - }, - [ChatConfiguration.UtilityModel]: { - type: 'string', - description: nls.localize('chat.utilityModel.description', "Override the language model used by built-in utility flows (titles, summaries, fallback responses, etc.). Leave empty to use the default model."), - default: '', - enum: UtilityModelContribution.modelIds, - enumItemLabels: UtilityModelContribution.modelLabels, - markdownEnumDescriptions: UtilityModelContribution.modelDescriptions - }, - [ChatConfiguration.UtilitySmallModel]: { - type: 'string', - description: nls.localize('chat.utilitySmallModel.description', "Override the language model used by built-in small/fast utility flows (commit messages, intent detection, inline-chat progress, etc.). A fast and inexpensive model is recommended. Leave empty to use the default model."), - default: '', - enum: UtilitySmallModelContribution.modelIds, - enumItemLabels: UtilitySmallModelContribution.modelLabels, - markdownEnumDescriptions: UtilitySmallModelContribution.modelDescriptions - }, - [ChatConfiguration.RequestQueueingDefaultAction]: { - type: 'string', - enum: ['queue', 'steer'], - enumDescriptions: [ - nls.localize('chat.requestQueuing.defaultAction.queue', "Queue the message to send after the current request completes."), - nls.localize('chat.requestQueuing.defaultAction.steer', "Steer the current request by sending the message immediately, signaling the current request to yield."), - ], - description: nls.localize('chat.requestQueuing.defaultAction.description', "Controls which action is the default for the queue button when a request is in progress."), - default: 'steer', - }, - [ChatConfiguration.EditModeHidden]: { - type: 'boolean', - description: nls.localize('chat.editMode.hidden', "When enabled, hides the Edit mode from the chat mode picker."), - default: true, - tags: ['experimental'], - experiment: { - mode: 'auto' - }, - policy: { - name: 'DeprecatedEditModeHidden', - category: PolicyCategory.InteractiveSession, - minimumVersion: '1.112', - localization: { - description: { - key: 'chat.editMode.hidden', - value: nls.localize('chat.editMode.hidden', "When enabled, hides the Edit mode from the chat mode picker."), - } - } - } - }, - [ChatConfiguration.EnableMath]: { - type: 'boolean', - description: nls.localize('chat.mathEnabled.description', "Enable math rendering in chat responses using KaTeX."), - default: true, - }, - [ChatConfiguration.ShowCodeBlockProgressAnimation]: { - type: 'boolean', - description: nls.localize('chat.codeBlock.showProgressAnimation.description', "When applying edits, show a progress animation in the code block pill. If disabled, shows the progress percentage instead."), - default: true, - tags: ['experimental'], - }, - [mcpDiscoverySection]: { - type: 'object', - properties: Object.fromEntries(allDiscoverySources.map(k => [k, { type: 'boolean', description: discoverySourceSettingsLabel[k] }])), - additionalProperties: false, - default: Object.fromEntries(allDiscoverySources.map(k => [k, false])), - markdownDescription: nls.localize('mcp.discovery.enabled', "Configures discovery of Model Context Protocol servers from configuration from various other applications."), - }, - [mcpGalleryServiceEnablementConfig]: { - type: 'boolean', - default: false, - tags: ['preview'], - description: nls.localize('chat.mcp.gallery.enabled', "Enables the default Marketplace for Model Context Protocol (MCP) servers."), - included: product.quality === 'stable' - }, - [mcpGalleryServiceUrlConfig]: { - type: 'string', - description: nls.localize('mcp.gallery.serviceUrl', "Configure the MCP Gallery service URL to connect to"), - default: '', - scope: ConfigurationScope.APPLICATION, - tags: ['usesOnlineServices', 'advanced'], - included: false, - policy: { - name: 'McpGalleryServiceUrl', - category: PolicyCategory.InteractiveSession, - minimumVersion: '1.101', - value: (policyData) => policyData.mcpRegistryUrl, - localization: { - description: { - key: 'mcp.gallery.serviceUrl', - value: nls.localize('mcp.gallery.serviceUrl', "Configure the MCP Gallery service URL to connect to"), - } - } - }, - }, - [PromptsConfig.INSTRUCTIONS_LOCATION_KEY]: { - type: 'object', - title: nls.localize( - 'chat.instructions.config.locations.title', - "Instructions File Locations", - ), - markdownDescription: nls.localize( - 'chat.instructions.config.locations.description', - "Specify location(s) of instructions files (`*{0}`) that can be attached in Chat sessions. [Learn More]({1}).\n\nRelative paths are resolved from the root folder(s) of your workspace.", - INSTRUCTION_FILE_EXTENSION, - INSTRUCTIONS_DOCUMENTATION_URL, - ), - default: { - ...DEFAULT_INSTRUCTIONS_SOURCE_FOLDERS.map((folder) => ({ [folder.path]: true })).reduce((acc, curr) => ({ ...acc, ...curr }), {}), - }, - additionalProperties: { type: 'boolean' }, - propertyNames: { - pattern: VALID_PROMPT_FOLDER_PATTERN, - patternErrorMessage: nls.localize('chat.instructionsLocations.invalidPath', "Paths must be relative or start with '~/'. Absolute paths and '\\' separators are not supported. Glob patterns are deprecated and will be removed in future versions."), - }, - restricted: true, - tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'], - examples: [ - { - [DEFAULT_INSTRUCTIONS_SOURCE_FOLDERS[0].path]: true, - }, - { - [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true, - '/Users/vscode/repos/instructions': true, - }, - ], - }, - [PromptsConfig.PROMPT_LOCATIONS_KEY]: { - type: 'object', - title: nls.localize( - 'chat.reusablePrompts.config.locations.title', - "Prompt File Locations", - ), - markdownDescription: nls.localize( - 'chat.reusablePrompts.config.locations.description', - "Specify location(s) of reusable prompt files (`*{0}`) that can be run in Chat sessions. [Learn More]({1}).\n\nRelative paths are resolved from the root folder(s) of your workspace.", - PROMPT_FILE_EXTENSION, - PROMPT_DOCUMENTATION_URL, - ), - default: { - [PROMPT_DEFAULT_SOURCE_FOLDER]: true, - }, - additionalProperties: { type: 'boolean' }, - unevaluatedProperties: { type: 'boolean' }, - propertyNames: { - pattern: VALID_PROMPT_FOLDER_PATTERN, - patternErrorMessage: nls.localize('chat.promptFileLocations.invalidPath', "Paths must be relative or start with '~/'. Absolute paths and '\\' separators are not supported. Glob patterns are deprecated and will be removed in future versions."), - }, - restricted: true, - tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'], - examples: [ - { - [PROMPT_DEFAULT_SOURCE_FOLDER]: true, - }, - { - [PROMPT_DEFAULT_SOURCE_FOLDER]: true, - '/Users/vscode/repos/prompts': true, - }, - ], - }, - [PromptsConfig.MODE_LOCATION_KEY]: { - type: 'object', - title: nls.localize( - 'chat.mode.config.locations.title', - "Mode File Locations", - ), - markdownDescription: nls.localize( - 'chat.mode.config.locations.description', - "Specify location(s) of custom chat mode files (`*{0}`). [Learn More]({1}).\n\nRelative paths are resolved from the root folder(s) of your workspace.", - LEGACY_MODE_FILE_EXTENSION, - AGENT_DOCUMENTATION_URL, - ), - default: { - [LEGACY_MODE_DEFAULT_SOURCE_FOLDER]: true, - }, - deprecationMessage: nls.localize('chat.mode.config.locations.deprecated', "This setting is deprecated and will be removed in future releases. Chat modes are now called custom agents and are located in `.github/agents`"), - additionalProperties: { type: 'boolean' }, - unevaluatedProperties: { type: 'boolean' }, - restricted: true, - tags: ['experimental', 'prompts', 'reusable prompts', 'prompt snippets', 'instructions'], - examples: [ - { - [LEGACY_MODE_DEFAULT_SOURCE_FOLDER]: true, - }, - { - [LEGACY_MODE_DEFAULT_SOURCE_FOLDER]: true, - '/Users/vscode/repos/chatmodes': true, - }, - ], - }, - [PromptsConfig.AGENTS_LOCATION_KEY]: { - type: 'object', - title: nls.localize( - 'chat.agents.config.locations.title', - "Agent File Locations", - ), - markdownDescription: nls.localize( - 'chat.agents.config.locations.description', - "Specify location(s) of custom agent files (`*{0}`). [Learn More]({1}).\n\nRelative paths are resolved from the root folder(s) of your workspace.", - AGENT_FILE_EXTENSION, - AGENT_DOCUMENTATION_URL, - ), - default: { - [AGENTS_SOURCE_FOLDER]: true, - [CLAUDE_AGENTS_SOURCE_FOLDER]: true, - [COPILOT_USER_AGENTS_SOURCE_FOLDER]: true, - }, - additionalProperties: { type: 'boolean' }, - propertyNames: { - pattern: VALID_PROMPT_FOLDER_PATTERN, - patternErrorMessage: nls.localize('chat.agentLocations.invalidPath', "Paths must be relative or start with '~/'. Absolute paths and '\\' separators are not supported."), - }, - restricted: true, - tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'], - examples: [ - { - [AGENTS_SOURCE_FOLDER]: true, - }, - { - [AGENTS_SOURCE_FOLDER]: true, - 'my-agents': true, - '../shared-agents': true, - '~/.copilot/agents': true, - }, - ], - }, - [PromptsConfig.USE_AGENT_MD]: { - type: 'boolean', - title: nls.localize('chat.useAgentMd.title', "Use AGENTS.md file",), - markdownDescription: nls.localize('chat.useAgentMd.description', "Controls whether instructions from `AGENTS.md` file found in a workspace roots are attached to all chat requests.",), - default: true, - restricted: true, - disallowConfigurationDefault: true, - tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'] - }, - [PromptsConfig.USE_NESTED_AGENT_MD]: { - type: 'boolean', - title: nls.localize('chat.useNestedAgentMd.title', "Use nested AGENTS.md files",), - markdownDescription: nls.localize('chat.useNestedAgentMd.description', "Controls whether instructions from nested `AGENTS.md` files found in the workspace are listed in all chat requests. The language model can load these skills on-demand if the `read` tool is available.",), - default: false, - restricted: true, - disallowConfigurationDefault: true, - tags: ['experimental', 'prompts', 'reusable prompts', 'prompt snippets', 'instructions'] - }, - [PromptsConfig.USE_CLAUDE_MD]: { - type: 'boolean', - title: nls.localize('chat.useClaudeMd.title', "Use CLAUDE.md file",), - markdownDescription: nls.localize('chat.useClaudeMd.description', "Controls whether instructions from `CLAUDE.md` file found in workspace roots, .claude and ~/.claude folder are attached to all chat requests.",), - default: true, - restricted: true, - disallowConfigurationDefault: true, - tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'] - }, - [PromptsConfig.USE_AGENT_SKILLS]: { - type: 'boolean', - title: nls.localize('chat.useAgentSkills.title', "Use Agent skills",), - markdownDescription: nls.localize('chat.useAgentSkills.description', "Controls whether skills are provided as specialized capabilities to the chat requests. Skills are loaded from the folders configured in `#chat.agentSkillsLocations#`. The language model can load these skills on-demand if the `read` tool is available. Learn more about [Agent Skills](https://aka.ms/vscode-agent-skills).",), - default: true, - restricted: true, - disallowConfigurationDefault: true, - tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'] - }, - [PromptsConfig.USE_SKILL_ADHERENCE_PROMPT]: { - type: 'boolean', - title: nls.localize('chat.useSkillAdherencePrompt.title', "Use Skill Adherence Prompt",), - markdownDescription: nls.localize('chat.useSkillAdherencePrompt.description', "Controls whether a stronger skill adherence prompt is used that encourages the model to immediately invoke skills when relevant rather than just announcing them."), - default: false, - restricted: true, - disallowConfigurationDefault: true, - tags: ['experimental', 'prompts', 'reusable prompts', 'prompt snippets', 'instructions'], - experiment: { - mode: 'auto' - } - }, - [PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS]: { - type: 'boolean', - title: nls.localize('chat.includeApplyingInstructions.title', "Include Applying Instructions",), - markdownDescription: nls.localize('chat.includeApplyingInstructions.description', "Controls whether instructions with a matching 'applyTo' attribute are automatically included in chat requests.",), - default: true, - restricted: true, - disallowConfigurationDefault: true, - tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'] - }, - [PromptsConfig.INCLUDE_REFERENCED_INSTRUCTIONS]: { - type: 'boolean', - title: nls.localize('chat.includeReferencedInstructions.title', "Include Referenced Instructions",), - markdownDescription: nls.localize('chat.includeReferencedInstructions.description', "Controls whether referenced instructions are automatically included in chat requests.",), - default: false, - restricted: true, - disallowConfigurationDefault: true, - tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'] - }, - [PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS]: { - type: 'boolean', - title: nls.localize('chat.useCustomizationsInParentRepos.title', "Use Customizations in Parent Repositories",), - markdownDescription: nls.localize('chat.useCustomizationsInParentRepos.description', "Controls whether to use chat customization files in parent repositories.",), - default: false, - restricted: true, - disallowConfigurationDefault: true, - tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'] - }, - [PromptsConfig.SKILLS_LOCATION_KEY]: { - type: 'object', - title: nls.localize('chat.agentSkillsLocations.title', "Agent Skills Locations",), - markdownDescription: nls.localize( - 'chat.agentSkillsLocations.description', - "Specify location(s) of agent skills (`{0}`) that can be used in Chat Sessions. [Learn More]({1}).\n\nEach path should contain skill subfolders with SKILL.md files (e.g., add `my-skills` if you have `my-skills/skillA/SKILL.md`). Relative paths are resolved from the root folder(s) of your workspace.", - SKILL_FILENAME, - SKILL_DOCUMENTATION_URL, - ), - default: { - ...DEFAULT_SKILL_SOURCE_FOLDERS.map((folder) => ({ [folder.path]: true })).reduce((acc, curr) => ({ ...acc, ...curr }), {}), - }, - additionalProperties: { type: 'boolean' }, - propertyNames: { - pattern: VALID_PROMPT_FOLDER_PATTERN, - patternErrorMessage: nls.localize('chat.agentSkillsLocations.invalidPath', "Paths must be relative or start with '~/'. Absolute paths and '\\' separators are not supported."), - }, - restricted: true, - tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'], - examples: [ - { - [DEFAULT_SKILL_SOURCE_FOLDERS[0].path]: true, - }, - { - [DEFAULT_SKILL_SOURCE_FOLDERS[0].path]: true, - 'my-skills': true, - '../shared-skills': true, - '~/.custom/skills': true, - }, - ], - }, - [PromptsConfig.HOOKS_LOCATION_KEY]: { - type: 'object', - title: nls.localize('chat.hookFilesLocations.title', "Hook File Locations",), - markdownDescription: nls.localize( - 'chat.hookFilesLocations.description', - "Specify paths to hook configuration files that define custom shell commands to execute at strategic points in an agent's workflow. [Learn More]({0}).\n\nRelative paths are resolved from the root folder(s) of your workspace. Supports Copilot hooks (`*.json`) and Claude Code hooks (`settings.json`, `settings.local.json`).", - HOOK_DOCUMENTATION_URL, - ), - default: { - ...DEFAULT_HOOK_FILE_PATHS.map((f) => ({ [f.path]: true })).reduce((acc, curr) => ({ ...acc, ...curr }), {}), - }, - additionalProperties: { type: 'boolean' }, - propertyNames: { - pattern: VALID_PROMPT_FOLDER_PATTERN, - patternErrorMessage: nls.localize('chat.hookFilesLocations.invalidPath', "Paths must be relative or start with '~/'. Absolute paths and '\\' separators are not supported."), - }, - restricted: true, - tags: ['preview', 'prompts', 'hooks', 'agent'], - examples: [ - { - [DEFAULT_HOOK_FILE_PATHS[0].path]: true, - }, - { - [DEFAULT_HOOK_FILE_PATHS[0].path]: true, - 'custom-hooks/hooks.json': true, - }, - ], - agentsWindow: { default: { '.claude/settings.local.json': false, '.claude/settings.json': false, '~/.claude/settings.json': false } }, - }, - [PromptsConfig.USE_CHAT_HOOKS]: { - type: 'boolean', - title: nls.localize('chat.useHooks.title', "Use Chat Hooks",), - markdownDescription: nls.localize('chat.useHooks.description', "Controls whether chat hooks are executed at strategic points during an agent's workflow. Hooks are loaded from the files configured in `#chat.hookFilesLocations#`.",), - default: true, - restricted: true, - disallowConfigurationDefault: true, - tags: ['preview', 'prompts', 'hooks', 'agent'], - policy: { - name: 'ChatHooks', - category: PolicyCategory.InteractiveSession, - minimumVersion: '1.109', - value: (policyData) => policyData.chat_preview_features_enabled === false ? false : undefined, - localization: { - description: { - key: 'chat.useHooks.description', - value: nls.localize('chat.useHooks.description', "Controls whether chat hooks are executed at strategic points during an agent's workflow. Hooks are loaded from the files configured in `#chat.hookFilesLocations#`.",) - } - }, - } - }, - [PromptsConfig.USE_CLAUDE_HOOKS]: { - type: 'boolean', - title: nls.localize('chat.useClaudeHooks.title', "Use Claude Hooks",), - markdownDescription: nls.localize('chat.useClaudeHooks.description', "Controls whether hooks from Claude configuration files can execute. When disabled, only Copilot-format hooks are used. Hooks are loaded from the files configured in `#chat.hookFilesLocations#`.",), - default: false, - restricted: true, - disallowConfigurationDefault: true, - tags: ['preview', 'prompts', 'hooks', 'agent'] - }, - [PromptsConfig.PROMPT_FILES_SUGGEST_KEY]: { - type: 'object', - scope: ConfigurationScope.RESOURCE, - title: nls.localize( - 'chat.promptFilesRecommendations.title', - "Prompt File Recommendations", - ), - markdownDescription: nls.localize( - 'chat.promptFilesRecommendations.description', - "Configure which prompt files to recommend in the chat welcome view. Each key is a prompt file name, and the value can be `true` to always recommend, `false` to never recommend, or a [when clause](https://aka.ms/vscode-when-clause) expression like `resourceExtname == .js` or `resourceLangId == markdown`.", - ), - default: {}, - additionalProperties: { - oneOf: [ - { type: 'boolean' }, - { type: 'string' } - ] - }, - tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'], - examples: [ - { - 'plan': true, - 'a11y-audit': 'resourceExtname == .html', - 'document': 'resourceLangId == markdown' - } - ], - }, - [ChatConfiguration.TodosShowWidget]: { - type: 'boolean', - default: true, - description: nls.localize('chat.tools.todos.showWidget', "Controls whether to show the todo list widget above the chat input. When enabled, the widget displays todo items created by the agent and updates as progress is made."), - }, - [ChatConfiguration.ThinkingStyle]: { - type: 'string', - default: 'fixedScrolling', - enum: ['collapsed', 'collapsedPreview', 'fixedScrolling'], - enumDescriptions: [ - nls.localize('chat.agent.thinkingMode.collapsed', "Thinking parts will be collapsed by default."), - nls.localize('chat.agent.thinkingMode.collapsedPreview', "Thinking parts will be expanded first, then collapse once we reach a part that is not thinking."), - nls.localize('chat.agent.thinkingMode.fixedScrolling', "Show thinking in a fixed-height streaming panel that auto-scrolls; click header to expand to full height."), - ], - description: nls.localize('chat.agent.thinkingStyle', "Controls how thinking is rendered."), - tags: ['experimental'], - }, - [ChatConfiguration.ThinkingGenerateTitles]: { - type: 'boolean', - default: true, - description: nls.localize('chat.agent.thinking.generateTitles', "Controls whether to use an LLM to generate summary titles for thinking sections."), - tags: ['experimental'], - }, - 'chat.agent.thinking.collapsedTools': { - type: 'string', - default: 'always', - enum: ['off', 'withThinking', 'always'], - enumDescriptions: [ - nls.localize('chat.agent.thinking.collapsedTools.off', "Tool calls are shown separately, not collapsed into thinking."), - nls.localize('chat.agent.thinking.collapsedTools.withThinking', "Tool calls are collapsed into thinking sections when thinking is present."), - nls.localize('chat.agent.thinking.collapsedTools.always', "Tool calls are always collapsed, even without thinking."), - ], - markdownDescription: nls.localize('chat.agent.thinking.collapsedTools', "Controls how tool calls are displayed in relation to thinking sections."), - tags: ['experimental'], - }, - [ChatConfiguration.TerminalToolsInThinking]: { - type: 'boolean', - default: true, - markdownDescription: nls.localize('chat.agent.thinking.terminalTools', "When enabled, terminal tool calls are displayed inside the thinking dropdown with a simplified view."), - tags: ['experimental'], - }, - [ChatConfiguration.SimpleTerminalCollapsible]: { - type: 'boolean', - default: true, - markdownDescription: nls.localize('chat.tools.terminal.simpleCollapsible', "When enabled, terminal tool calls are always displayed in a collapsible container with a simplified view."), - tags: ['experimental'], - }, - [ChatConfiguration.CompressOutputEnabled]: { - type: 'boolean', - default: false, - markdownDescription: nls.localize('chat.tools.compressOutput.enabled', "Post-process tool output (for example `git diff`, `ls -l`, or `npm install`) to reduce token usage before it is sent to the model."), - tags: ['preview'], - experiment: { - mode: 'auto' - } - }, - [ChatConfiguration.ThinkingPhrases]: { - type: 'object', - default: { - mode: 'append', - phrases: [] - }, - properties: { - mode: { - type: 'string', - enum: ['replace', 'append'], - default: 'append', - description: nls.localize('chat.agent.thinking.phrases.mode', "'replace' replaces all default phrases entirely; 'append' adds your phrases to all default categories.") - }, - phrases: { - type: 'array', - items: { type: 'string' }, - default: [], - description: nls.localize('chat.agent.thinking.phrases.phrases', "Custom loading messages to show during thinking, working progress, terminal, and tool operations.") - } - }, - additionalProperties: false, - markdownDescription: nls.localize('chat.agent.thinking.phrases', "Customize the loading messages shown during agent thinking and progress indicators. Use `\"mode\": \"replace\"` to use only your phrases, or `\"mode\": \"append\"` to add them to the defaults."), - tags: ['experimental'], - }, - [ChatConfiguration.AutoExpandToolFailures]: { - type: 'boolean', - default: true, - markdownDescription: nls.localize('chat.tools.autoExpandFailures', "When enabled, tool failures are automatically expanded in the chat UI to show error details."), - }, - [ChatConfiguration.AIDisabled]: { - type: 'boolean', - description: nls.localize('chat.disableAIFeatures', "Disable and hide built-in AI features provided by GitHub Copilot, including chat and inline suggestions."), - default: false, - scope: ConfigurationScope.WINDOW, - }, - [ChatConfiguration.OfflineByok]: { - type: 'boolean', - description: nls.localize('chat.offlineByok', "Experimental: enable BYOK chat features without GitHub sign-in."), - default: false, - scope: ConfigurationScope.WINDOW, - included: false, - }, - [ChatConfiguration.TitleBarSignInEnabled]: { - type: 'boolean', - description: nls.localize('chat.titleBar.signIn.enabled', "Controls whether the Copilot Sign In button is shown in the title bar when signed out. When disabled, the Sign In affordance falls back to the status bar."), - default: true, - }, - 'chat.approvedAccountOrganizations': { - type: 'array', - items: { type: 'string' }, - description: nls.localize('chat.approvedAccountOrganizations', "List of GitHub organization logins whose members are permitted to use AI features. When set to a non-empty list, AI features are disabled until the user signs into a GitHub account that belongs to one of the specified organizations and account-level policy data has been resolved. Set to '*' to allow any authenticated GitHub or GitHub Enterprise account."), - default: [], - included: false, - policy: { - name: 'ChatApprovedAccountOrganizations', - category: PolicyCategory.InteractiveSession, - minimumVersion: '1.118', - localization: { - description: { - key: 'chat.approvedAccountOrganizations.policy.description', - value: nls.localize('chat.approvedAccountOrganizations.policy.description', "Setting this policy to a non-empty list activates the Approved Account gate: all AI features are disabled until the user signs into a GitHub account whose organizations intersect this list AND the account-side policy data has resolved. Comparison is case-insensitive. Use '*' as a wildcard to accept any signed-in GitHub or GHE account (use this for GHE deployments where the organization list is not surfaced).") - } - } - } - }, - 'chat.allowAnonymousAccess': { // TODO@bpasero remove me eventually - type: 'boolean', - description: nls.localize('chat.allowAnonymousAccess', "Controls whether anonymous access is allowed in chat."), - default: false, - tags: ['experimental'], - experiment: { - mode: 'auto' - } - }, - [ChatConfiguration.GrowthNotificationEnabled]: { - type: 'boolean', - description: nls.localize('chat.growthNotification', "Controls whether to show a growth notification in the agent sessions view to encourage new users to try Copilot."), - default: false, - tags: ['experimental'], - experiment: { - mode: 'auto' - } - }, - [ChatConfiguration.RestoreLastPanelSession]: { - type: 'boolean', - description: nls.localize('chat.restoreLastPanelSession', "Controls whether the last session is restored in panel after restart."), - default: false - }, - [ChatConfiguration.ExitAfterDelegation]: { - type: 'boolean', - description: nls.localize('chat.exitAfterDelegation', "Controls whether the chat panel automatically exits after delegating a request to another session."), - default: false, - tags: ['preview'], - }, - 'chat.extensionUnification.enabled': { - type: 'boolean', - description: nls.localize('chat.extensionUnification.enabled', "Enables the unification of GitHub Copilot extensions. When enabled, all GitHub Copilot functionality is served from the GitHub Copilot Chat extension. When disabled, the GitHub Copilot and GitHub Copilot Chat extensions operate independently."), - default: true, - tags: ['experimental'], - experiment: { - mode: 'auto' - } - }, - [ChatConfiguration.GeneralPurposeAgentEnabled]: { - type: 'boolean', - description: nls.localize('chat.generalPurposeAgent.enabled', "Controls whether the built-in General Purpose agent is available as a subagent."), - default: false, - tags: ['experimental', 'advanced'], - experiment: { - mode: 'auto' - } - }, - [ChatConfiguration.SubagentsAllowInvocationsFromSubagents]: { - type: 'boolean', - description: nls.localize('chat.subagents.allowInvocationsFromSubagents', "Allow subagents to invoke subagents."), - markdownDescription: nls.localize('chat.subagents.allowInvocationsFromSubagents.md', "Controls whether subagents can invoke other subagents. When enabled, nesting is limited to a maximum depth of 5."), - default: false, - experiment: { - mode: 'auto' - } - }, - - [ChatConfiguration.ChatCustomizationHarnessSelectorEnabled]: { - type: 'boolean', - tags: ['preview'], - description: nls.localize('chat.customizations.harnessSelector.enabled', "Controls whether the harness selector is shown in the Chat Customizations editor sidebar. When disabled, the editor always shows all customizations without filtering."), - default: true, - }, - [ChatConfiguration.ChatCustomizationsStructuredPreviewEnabled]: { - type: 'boolean', - tags: ['preview'], - description: nls.localize('chat.customizations.structuredPreview.enabled', "Controls whether the Chat Customizations editor shows a structured preview for markdown customization files (agents, skills, instructions, prompts). When disabled, the editor always opens the raw markdown in the embedded code editor."), - default: false, - }, - [ChatConfiguration.UseChatSessionCustomizationsForCustomAgents]: { - type: 'boolean', - description: nls.localize('chat.customizations.useChatSessionCustomizationsForCustomAgents', "When enabled, custom agents shown in the chat mode picker are sourced from the customization harness service (scoped per session type) instead of the prompts service."), - default: false, - tags: ['experimental', 'advanced'], - experiment: { - mode: 'auto' - } - }, - } -}); -Registry.as(EditorExtensions.EditorPane).registerEditorPane( - EditorPaneDescriptor.create( - ChatEditor, - ChatEditorInput.EditorID, - nls.localize('chat', "Chat") - ), - [ - new SyncDescriptor(ChatEditorInput) - ] -); -Registry.as(EditorExtensions.EditorPane).registerEditorPane( - EditorPaneDescriptor.create( - ChatDebugEditor, - ChatDebugEditorInput.ID, - nls.localize('chatDebug', "Debug View") - ), - [ - new SyncDescriptor(ChatDebugEditorInput) - ] -); -Registry.as(EditorExtensions.EditorPane).registerEditorPane( - EditorPaneDescriptor.create( - AgentPluginEditor, - AgentPluginEditor.ID, - nls.localize('agentPlugin', "Agent Plugin") - ), - [ - new SyncDescriptor(AgentPluginEditorInput) - ] -); -Registry.as(Extensions.ConfigurationMigration).registerConfigurationMigrations([ - { - key: 'chat.experimental.detectParticipant.enabled', - migrateFn: (value, _accessor) => ([ - ['chat.experimental.detectParticipant.enabled', { value: undefined }], - ['chat.detectParticipant.enabled', { value: value !== false }] - ]) - }, - { - key: 'chat.useClaudeSkills', - migrateFn: (value, _accessor) => ([ - ['chat.useClaudeSkills', { value: undefined }], - ['chat.useAgentSkills', { value }] - ]) - }, - { - key: mcpDiscoverySection, - migrateFn: (value: unknown) => { - if (typeof value === 'boolean') { - return { value: Object.fromEntries(allDiscoverySources.map(k => [k, value])) }; - } - - return { value }; - } - }, - { - key: ChatConfiguration.NotifyWindowOnConfirmation, - migrateFn: (value: unknown) => { - if (value === true) { - return { value: ChatNotificationMode.WindowNotFocused }; - } else if (value === false) { - return { value: ChatNotificationMode.Off }; - } - return []; - } - }, - { - key: ChatConfiguration.NotifyWindowOnResponseReceived, - migrateFn: (value: unknown) => { - if (value === true) { - return { value: ChatNotificationMode.WindowNotFocused }; - } else if (value === false) { - return { value: ChatNotificationMode.Off }; - } - return []; - } - }, - { - key: 'chat.plugins.paths', - migrateFn: (value: unknown, _accessor) => ([ - ['chat.plugins.paths', { value: undefined }], - [ChatConfiguration.PluginLocations, { value }] - ]) - }, - { - key: AgentNetworkDomainSettingId.DeprecatedSandboxAllowedNetworkDomains, - migrateFn: (value, accessor) => { - const pairs: ConfigurationKeyValuePairs = []; - pairs.push([AgentNetworkDomainSettingId.DeprecatedSandboxAllowedNetworkDomains, { value: undefined }]); - if (value !== undefined && accessor(AgentNetworkDomainSettingId.AllowedNetworkDomains) === undefined) { - pairs.push([AgentNetworkDomainSettingId.AllowedNetworkDomains, { value }]); - } - return pairs; - } - }, - { - key: AgentNetworkDomainSettingId.DeprecatedSandboxDeniedNetworkDomains, - migrateFn: (value, accessor) => { - const pairs: ConfigurationKeyValuePairs = []; - pairs.push([AgentNetworkDomainSettingId.DeprecatedSandboxDeniedNetworkDomains, { value: undefined }]); - if (value !== undefined && accessor(AgentNetworkDomainSettingId.DeniedNetworkDomains) === undefined) { - pairs.push([AgentNetworkDomainSettingId.DeniedNetworkDomains, { value }]); - } - return pairs; - } - }, - { - key: AgentNetworkDomainSettingId.DeprecatedOldAllowedNetworkDomains, - migrateFn: (value, accessor) => { - const pairs: ConfigurationKeyValuePairs = []; - pairs.push([AgentNetworkDomainSettingId.DeprecatedOldAllowedNetworkDomains, { value: undefined }]); - if (value !== undefined && accessor(AgentNetworkDomainSettingId.AllowedNetworkDomains) === undefined) { - pairs.push([AgentNetworkDomainSettingId.AllowedNetworkDomains, { value }]); - } - return pairs; - } - }, - { - key: AgentNetworkDomainSettingId.DeprecatedOldDeniedNetworkDomains, - migrateFn: (value, accessor) => { - const pairs: ConfigurationKeyValuePairs = []; - pairs.push([AgentNetworkDomainSettingId.DeprecatedOldDeniedNetworkDomains, { value: undefined }]); - if (value !== undefined && accessor(AgentNetworkDomainSettingId.DeniedNetworkDomains) === undefined) { - pairs.push([AgentNetworkDomainSettingId.DeniedNetworkDomains, { value }]); - } - return pairs; - } - }, -]); - -class ChatResolverContribution extends Disposable { - - static readonly ID = 'workbench.contrib.chatResolver'; - - private readonly _editorRegistrations = this._register(new DisposableMap()); - - constructor( - @IChatSessionsService chatSessionsService: IChatSessionsService, - @IEditorResolverService private readonly editorResolverService: IEditorResolverService, - @IInstantiationService private readonly instantiationService: IInstantiationService, - ) { - super(); - - this._registerEditor(Schemas.vscodeChatEditor); - this._registerEditor(Schemas.vscodeLocalChatSession); - - this._register(chatSessionsService.onDidChangeContentProviderSchemes((e) => { - for (const scheme of e.added) { - this._registerEditor(scheme); - } - for (const scheme of e.removed) { - this._editorRegistrations.deleteAndDispose(scheme); - } - })); - - for (const scheme of chatSessionsService.getContentProviderSchemes()) { - this._registerEditor(scheme); - } - } - - private _registerEditor(scheme: string): void { - this._editorRegistrations.set(scheme, this.editorResolverService.registerEditor(`${scheme}:**/**`, - { - id: ChatEditorInput.EditorID, - label: nls.localize('chat', "Chat"), - priority: RegisteredEditorPriority.builtin - }, - { - singlePerResource: true, - canSupportResource: resource => resource.scheme === scheme, - }, - { - createEditorInput: ({ resource, options }) => { - return { - editor: this.instantiationService.createInstance(ChatEditorInput, resource, options as IChatEditorOptions), - options - }; - } - } - )); - } -} - -class CopilotTelemetryContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.copilotTelemetry'; - - constructor( - @ITelemetryService private readonly telemetryService: ITelemetryService, - @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, - ) { - super(); - - this.updateCopilotTrackingId(); - - this._register(this.chatEntitlementService.onDidChangeEntitlement(() => { - this.updateCopilotTrackingId(); - })); - } - - private updateCopilotTrackingId(): void { - const copilotTrackingId = this.chatEntitlementService.copilotTrackingId; - if (copilotTrackingId) { - // __GDPR__COMMON__ "common.copilotTrackingId" : { "endPoint": "GoogleAnalyticsID", "classification": "EndUserPseudonymizedInformation", "purpose": "BusinessInsight", "comment": "The anonymized Copilot analytics tracking ID from the entitlement API." } - this.telemetryService.setCommonProperty('common.copilotTrackingId', copilotTrackingId); - } - } -} - -class ChatDebugResolverContribution implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.chatDebugResolver'; - - constructor( - @IEditorResolverService editorResolverService: IEditorResolverService, - ) { - editorResolverService.registerEditor( - `${ChatDebugEditorInput.RESOURCE.scheme}:**/**`, - { - id: ChatDebugEditorInput.ID, - label: nls.localize('chatDebug', "Debug View"), - priority: RegisteredEditorPriority.exclusive - }, - { - singlePerResource: true, - canSupportResource: resource => resource.scheme === ChatDebugEditorInput.RESOURCE.scheme - }, - { - createEditorInput: () => { - return { - editor: ChatDebugEditorInput.instance, - options: { pinned: true } - }; - } - } - ); - } -} - -class ChatAgentSettingContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.chatAgentSetting'; - private readonly newChatButtonExperimentIcon; - - constructor( - @IWorkbenchAssignmentService private readonly experimentService: IWorkbenchAssignmentService, - @IChatEntitlementService private readonly entitlementService: IChatEntitlementService, - @IContextKeyService private readonly contextKeyService: IContextKeyService, - ) { - super(); - this.newChatButtonExperimentIcon = ChatContextKeys.newChatButtonExperimentIcon.bindTo(this.contextKeyService); - this.registerMaxRequestsSetting(); - this.registerNewChatButtonIcon(); - this.registerDefaultModeSetting(); - } - - - private registerMaxRequestsSetting(): void { - let lastNode: IConfigurationNode | undefined; - const registerMaxRequestsSetting = () => { - const treatmentId = this.entitlementService.entitlement === ChatEntitlement.Free ? - 'chatAgentMaxRequestsFree' : - 'chatAgentMaxRequestsPro'; - this.experimentService.getTreatment(treatmentId).then((value) => { - const node: IConfigurationNode = { - id: 'chatSidebar', - title: nls.localize('interactiveSessionConfigurationTitle', "Chat"), - type: 'object', - properties: { - 'chat.agent.maxRequests': { - type: 'number', - markdownDescription: nls.localize('chat.agent.maxRequests', "The maximum number of requests to allow per-turn when using an agent. When the limit is reached, will ask to confirm to continue."), - default: value ?? 50, - order: 2, - agentsWindow: { default: 1000 }, - }, - } - }; - configurationRegistry.updateConfigurations({ remove: lastNode ? [lastNode] : [], add: [node] }); - lastNode = node; - }); - }; - this._register(Event.runAndSubscribe(Event.debounce(this.entitlementService.onDidChangeEntitlement, () => { }, 1000), () => registerMaxRequestsSetting())); - } - - private registerNewChatButtonIcon(): void { - this.experimentService.getTreatment('chatNewButtonIcon').then((value) => { - const supportedValues = ['copilot', 'new-session', 'comment']; - if (typeof value === 'string' && supportedValues.includes(value)) { - this.newChatButtonExperimentIcon.set(value); - } else { - this.newChatButtonExperimentIcon.reset(); - } - }); - } - - private registerDefaultModeSetting(): void { - this.experimentService.getTreatment('chatDefaultNewSessionMode').then(value => { - const node: IConfigurationNode = { - id: 'chatSidebar', - title: nls.localize('interactiveSessionConfigurationTitle', "Chat"), - type: 'object', - properties: { - [ChatConfiguration.DefaultNewSessionMode]: { - type: 'string', - description: nls.localize('chat.newSession.defaultMode', "The default mode for new chat sessions. When empty, the chat view's default mode is used."), - default: typeof value === 'string' ? value : '', - } - } - }; - configurationRegistry.updateConfigurations({ add: [node], remove: [] }); - }); - } -} - -class ChatForegroundSessionCountContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.chatForegroundSessionCount'; - - private readonly foregroundSessionCountContextKey: IContextKey; - - constructor( - @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, - @IViewsService private readonly viewsService: IViewsService, - @IEditorService private readonly editorService: IEditorService, - ) { - super(); - this.foregroundSessionCountContextKey = ChatContextKeys.foregroundSessionCount.bindTo(this.contextKeyService); - - this._register(this.chatWidgetService.onDidAddWidget(() => { - this.updateForegroundSessionCount(); - })); - - this._register(this.editorService.onDidVisibleEditorsChange(() => { - this.updateForegroundSessionCount(); - })); - - this._register(Event.filter(this.viewsService.onDidChangeViewVisibility, e => e.id === ChatViewId)(() => { - this.updateForegroundSessionCount(); - })); - - this.updateForegroundSessionCount(); - } - - private updateForegroundSessionCount(): void { - let count = this.viewsService.isViewVisible(ChatViewId) ? 1 : 0; - - for (const widget of this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Chat)) { - if (widget.domNode.offsetParent === null) { - continue; - } - - if (isIChatViewViewContext(widget.viewContext)) { - continue; - } - - if (isIChatResourceViewContext(widget.viewContext) && widget.viewContext.isQuickChat) { - continue; - } - - count++; - } - - this.foregroundSessionCountContextKey.set(count); - } -} - - -/** - * Given builtin and custom modes, returns only the custom mode IDs that should have actions registered. - * Custom modes whose names conflict with builtin modes are excluded. - * If there are name collisions among custom modes, the later mode in the list wins. - */ -function getCustomModesWithUniqueNames(builtinModes: readonly IChatMode[], customModes: readonly IChatMode[]): Set { - const customModeIds = new Set(); - const builtinNames = new Set(builtinModes.map(mode => mode.name.get())); - const customNameToId = new Map(); - - for (const mode of customModes) { - const modeName = mode.name.get(); - - // Skip custom modes that conflict with builtin mode names - if (builtinNames.has(modeName)) { - continue; - } - - // If there is a name collision among custom modes, the later one in the list wins - const existingId = customNameToId.get(modeName); - if (existingId) { - customModeIds.delete(existingId); - } - - customNameToId.set(modeName, mode.id); - customModeIds.add(mode.id); - } - - return customModeIds; -} - -/** - * Workbench contribution to register actions for custom chat modes via events - */ -class ChatAgentActionsContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.chatAgentActions'; - - private readonly _modeActionDisposables = new DisposableMap(); - - constructor( - @IChatModeService _chatModeService: IChatModeService, - @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, - ) { - super(); - this._store.add(this._modeActionDisposables); - - const focusedWidget = observableFromEvent(this, this.chatWidgetService.onDidChangeFocusedSession, () => this.chatWidgetService.lastFocusedWidget); - this._register(autorun(reader => { - const chatModes = focusedWidget.read(reader)?.input.currentChatModesObs.read(reader); - this._syncModeActions(chatModes); - })); - } - - private _syncModeActions(chatModes: IChatModes | undefined): void { - if (!chatModes) { - this._modeActionDisposables.clearAndDisposeAll(); - return; - } - - const { builtin, custom } = chatModes; - const currentModeIds = getCustomModesWithUniqueNames(builtin, custom); - - // Remove modes that no longer exist and those replaced by modes later in the list with same name. - for (const modeId of this._modeActionDisposables.keys()) { - if (!currentModeIds.has(modeId)) { - this._modeActionDisposables.deleteAndDispose(modeId); - } - } - - // Register new modes. - for (const mode of custom) { - if (currentModeIds.has(mode.id) && !this._modeActionDisposables.has(mode.id)) { - this._registerModeAction(mode); - } - } - } - - private _registerModeAction(mode: IChatMode): void { - const actionClass = class extends ModeOpenChatGlobalAction { - constructor() { - super(mode); - } - }; - this._modeActionDisposables.set(mode.id, registerAction2(actionClass)); - } -} - -class HookSchemaAssociationContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.hookSchemaAssociation'; - - private readonly _registrations = this._register(new DisposableStore()); - - constructor( - @IConfigurationService private readonly _configurationService: IConfigurationService, - @IPathService private readonly _pathService: IPathService, - ) { - super(); - this._updateAssociations(); - this._register(this._configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(PromptsConfig.HOOKS_LOCATION_KEY)) { - this._updateAssociations(); - } - })); - } - - private async _updateAssociations(): Promise { - this._registrations.clear(); - - const folders = PromptsConfig.promptSourceFolders(this._configurationService, PromptsType.hook); - const userHomeUri = await this._pathService.userHome(); - const userHome = userHomeUri.fsPath ?? userHomeUri.path; - - for (const folder of folders) { - // Skip Claude settings files — they use a different schema format - if (folder.source === PromptFileSource.ClaudeWorkspace || folder.source === PromptFileSource.ClaudeWorkspaceLocal || folder.source === PromptFileSource.ClaudePersonal) { - continue; - } - - // Expand tilde paths to absolute paths so the JSON language service can match them - const resolvedPath = isTildePath(folder.path) - ? userHome + folder.path.substring(1) - : folder.path; - - // If it's a specific .json file, use it directly; otherwise treat as directory - const glob = resolvedPath.toLowerCase().endsWith('.json') - ? resolvedPath - : `${resolvedPath}/*.json`; - - this._registrations.add( - jsonContributionRegistry.registerSchemaAssociation(HOOK_SCHEMA_URI, glob) - ); - } - } -} - -class ToolReferenceNamesContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.toolReferenceNames'; - - constructor( - @ILanguageModelToolsService private readonly _languageModelToolsService: ILanguageModelToolsService, - ) { - super(); - this._updateToolReferenceNames(); - this._register(this._languageModelToolsService.onDidChangeTools(() => this._updateToolReferenceNames())); - } - - private _updateToolReferenceNames(): void { - const tools = - Array.from(this._languageModelToolsService.getAllToolsIncludingDisabled()) - .filter((tool): tool is typeof tool & { toolReferenceName: string } => typeof tool.toolReferenceName === 'string') - .sort((a, b) => a.toolReferenceName.localeCompare(b.toolReferenceName)); - toolReferenceNameEnumValues.length = 0; - toolReferenceNameEnumDescriptions.length = 0; - for (const tool of tools) { - toolReferenceNameEnumValues.push(tool.toolReferenceName); - toolReferenceNameEnumDescriptions.push(nls.localize( - 'chat.toolReferenceName.description', - "{0} - {1}", - tool.toolReferenceName, - tool.userDescription || tool.displayName - )); - } - configurationRegistry.notifyConfigurationSchemaUpdated({ - id: 'chatSidebar', - properties: { - [ChatConfiguration.EligibleForAutoApproval]: {} - } - }); - } -} - -AccessibleViewRegistry.register(new ChatTerminalOutputAccessibleView()); -AccessibleViewRegistry.register(new ChatResponseAccessibleView()); -AccessibleViewRegistry.register(new PanelChatAccessibilityHelp()); -AccessibleViewRegistry.register(new QuickChatAccessibilityHelp()); -AccessibleViewRegistry.register(new EditsChatAccessibilityHelp()); -AccessibleViewRegistry.register(new AgentChatAccessibilityHelp()); - -registerEditorFeature(ChatInputBoxContentProvider); -Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer(ChatEditorInput.TypeID, ChatEditorInputSerializer); -Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer(ChatDebugEditorInput.ID, ChatDebugEditorInputSerializer); - -registerWorkbenchContribution2(CopilotTelemetryContribution.ID, CopilotTelemetryContribution, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(ChatResolverContribution.ID, ChatResolverContribution, WorkbenchPhase.BlockStartup); -registerWorkbenchContribution2(ChatDebugResolverContribution.ID, ChatDebugResolverContribution, WorkbenchPhase.BlockStartup); -registerWorkbenchContribution2(PromptsDebugContribution.ID, PromptsDebugContribution, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(ChatLanguageModelsDataContribution.ID, ChatLanguageModelsDataContribution, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(ChatSlashCommandsContribution.ID, ChatSlashCommandsContribution, WorkbenchPhase.Eventually); -registerWorkbenchContribution2(ChatSessionOptionSlashCommandsContribution.ID, ChatSessionOptionSlashCommandsContribution, WorkbenchPhase.Eventually); - -registerWorkbenchContribution2(ChatExtensionPointHandler.ID, ChatExtensionPointHandler, WorkbenchPhase.BlockStartup); -registerWorkbenchContribution2(LanguageModelToolsExtensionPointHandler.ID, LanguageModelToolsExtensionPointHandler, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(ChatPromptFilesExtensionPointHandler.ID, ChatPromptFilesExtensionPointHandler, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(ChatCompatibilityNotifier.ID, ChatCompatibilityNotifier, WorkbenchPhase.Eventually); -registerWorkbenchContribution2(CodeBlockActionRendering.ID, CodeBlockActionRendering, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(ChatCopyActionRendering.ID, ChatCopyActionRendering, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(ChatImplicitContextContribution.ID, ChatImplicitContextContribution, WorkbenchPhase.Eventually); -registerWorkbenchContribution2(ChatViewsWelcomeHandler.ID, ChatViewsWelcomeHandler, WorkbenchPhase.BlockStartup); -registerWorkbenchContribution2(ChatGettingStartedContribution.ID, ChatGettingStartedContribution, WorkbenchPhase.Eventually); -registerWorkbenchContribution2(ChatSetupContribution.ID, ChatSetupContribution, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(HasByokModelsContribution.ID, HasByokModelsContribution, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(ChatTeardownContribution.ID, ChatTeardownContribution, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(ChatStatusBarEntry.ID, ChatStatusBarEntry, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(BuiltinToolsContribution.ID, BuiltinToolsContribution, WorkbenchPhase.Eventually); -registerWorkbenchContribution2(UsagesToolContribution.ID, UsagesToolContribution, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(RenameToolContribution.ID, RenameToolContribution, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(ChatAgentSettingContribution.ID, ChatAgentSettingContribution, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(ChatForegroundSessionCountContribution.ID, ChatForegroundSessionCountContribution, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(ChatAgentActionsContribution.ID, ChatAgentActionsContribution, WorkbenchPhase.Eventually); -registerWorkbenchContribution2(HookSchemaAssociationContribution.ID, HookSchemaAssociationContribution, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(ToolReferenceNamesContribution.ID, ToolReferenceNamesContribution, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(ChatAgentRecommendation.ID, ChatAgentRecommendation, WorkbenchPhase.Eventually); -registerWorkbenchContribution2(ChatEditingEditorAccessibility.ID, ChatEditingEditorAccessibility, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(ChatQueuePickerRendering.ID, ChatQueuePickerRendering, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(ChatEditingEditorOverlay.ID, ChatEditingEditorOverlay, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(ChatEditingEditorContextKeys.ID, ChatEditingEditorContextKeys, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(ChatTransferContribution.ID, ChatTransferContribution, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(ChatContextContributions.ID, ChatContextContributions, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(PromptUrlHandler.ID, PromptUrlHandler, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(PluginUrlHandler.ID, PluginUrlHandler, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(ChatEditingNotebookFileSystemProviderContrib.ID, ChatEditingNotebookFileSystemProviderContrib, WorkbenchPhase.BlockStartup); -registerWorkbenchContribution2(ChatResponseResourceWorkbenchContribution.ID, ChatResponseResourceWorkbenchContribution, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(UserToolSetsContributions.ID, UserToolSetsContributions, WorkbenchPhase.Eventually); -registerWorkbenchContribution2(PromptLanguageFeaturesProvider.ID, PromptLanguageFeaturesProvider, WorkbenchPhase.Eventually); -registerWorkbenchContribution2(ChatWindowNotifier.ID, ChatWindowNotifier, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(ChatRepoInfoContribution.ID, ChatRepoInfoContribution, WorkbenchPhase.Eventually); -registerWorkbenchContribution2(AgentPluginRecommendations.ID, AgentPluginRecommendations, WorkbenchPhase.Eventually); -registerWorkbenchContribution2(PluginAutoUpdate.ID, PluginAutoUpdate, WorkbenchPhase.Eventually); - -registerChatActions(); -registerChatAccessibilityActions(); -registerChatCopyActions(); -registerChatOpenAgentDebugPanelAction(); -registerChatCodeBlockActions(); -registerChatCodeCompareBlockActions(); -registerChatFileTreeActions(); -registerChatPromptNavigationActions(); -registerChatTitleActions(); -registerChatExecuteActions(); -registerChatQueueActions(); -registerQuickChatActions(); -registerChatExportActions(); -registerChatForkActions(); -registerMoveActions(); -registerNewChatActions(); -registerChatContextActions(); -registerChatDeveloperActions(); -registerChatEditorActions(); -registerChatElicitationActions(); -registerChatToolActions(); -registerLanguageModelActions(); -registerChatPluginActions(); -registerPlanReviewFeedbackEditorActions(); -registerAction2(ConfigureToolSets); -registerEditorFeature(ChatPasteProvidersFeature); - -agentPluginDiscoveryRegistry.register(new SyncDescriptor(ConfiguredAgentPluginDiscovery)); -agentPluginDiscoveryRegistry.register(new SyncDescriptor(MarketplaceAgentPluginDiscovery)); -agentPluginDiscoveryRegistry.register(new SyncDescriptor(ExtensionAgentPluginDiscovery)); -agentPluginDiscoveryRegistry.register(new SyncDescriptor(CopilotCliAgentPluginDiscovery)); - -registerSingleton(IChatResponseResourceFileSystemProvider, ChatResponseResourceFileSystemProvider, InstantiationType.Delayed); -registerSingleton(IChatTransferService, ChatTransferService, InstantiationType.Delayed); -registerSingleton(IChatService, ChatService, InstantiationType.Delayed); -registerSingleton(IChatWidgetService, ChatWidgetService, InstantiationType.Delayed); -registerSingleton(IQuickChatService, QuickChatService, InstantiationType.Delayed); -registerSingleton(IChatAccessibilityService, ChatAccessibilityService, InstantiationType.Delayed); -registerSingleton(IChatWidgetHistoryService, ChatWidgetHistoryService, InstantiationType.Delayed); -registerSingleton(ILanguageModelsConfigurationService, LanguageModelsConfigurationService, InstantiationType.Delayed); -registerSingleton(ILanguageModelsService, LanguageModelsService, InstantiationType.Delayed); -registerSingleton(ILanguageModelStatsService, LanguageModelStatsService, InstantiationType.Delayed); -registerSingleton(IChatSlashCommandService, ChatSlashCommandService, InstantiationType.Delayed); -registerSingleton(IChatAgentService, ChatAgentService, InstantiationType.Delayed); -registerSingleton(IChatAgentNameService, ChatAgentNameService, InstantiationType.Delayed); -registerSingleton(IChatVariablesService, ChatVariablesService, InstantiationType.Delayed); -registerSingleton(IAgentPluginService, AgentPluginService, InstantiationType.Delayed); -registerSingleton(IPluginMarketplaceService, PluginMarketplaceService, InstantiationType.Delayed); -registerSingleton(IWorkspacePluginSettingsService, WorkspacePluginSettingsService, InstantiationType.Delayed); -registerSingleton(IAgentPluginRepositoryService, AgentPluginRepositoryService, InstantiationType.Delayed); -registerSingleton(IPluginGitService, BrowserPluginGitCommandService, InstantiationType.Delayed); -registerSingleton(IPluginInstallService, PluginInstallService, InstantiationType.Delayed); -registerSingleton(ILanguageModelToolsService, LanguageModelToolsService, InstantiationType.Delayed); -registerSingleton(IToolResultCompressor, ToolResultCompressorService, InstantiationType.Delayed); -registerSingleton(ILanguageModelToolsConfirmationService, LanguageModelToolsConfirmationService, InstantiationType.Delayed); -registerSingleton(IChatToolRiskAssessmentService, ChatToolRiskAssessmentService, InstantiationType.Delayed); -registerSingleton(IVoiceChatService, VoiceChatService, InstantiationType.Delayed); -registerSingleton(IChatCodeBlockContextProviderService, ChatCodeBlockContextProviderService, InstantiationType.Delayed); -registerSingleton(ICodeMapperService, CodeMapperService, InstantiationType.Delayed); -registerSingleton(IChatEditingService, ChatEditingService, InstantiationType.Delayed); -registerSingleton(IChatMarkdownAnchorService, ChatMarkdownAnchorService, InstantiationType.Delayed); -registerSingleton(IAgentNetworkFilterService, AgentNetworkFilterService, InstantiationType.Delayed); -registerSingleton(ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService, InstantiationType.Delayed); -registerSingleton(IPromptsService, PromptsService, InstantiationType.Delayed); -registerSingleton(IChatContextPickService, ChatContextPickService, InstantiationType.Delayed); -registerSingleton(IChatModeService, ChatModeService, InstantiationType.Delayed); -registerSingleton(IChatAttachmentResolveService, ChatAttachmentResolveService, InstantiationType.Delayed); -registerSingleton(IChatAttachmentWidgetRegistry, ChatAttachmentWidgetRegistry, InstantiationType.Delayed); -registerSingleton(IChatTodoListService, ChatTodoListService, InstantiationType.Delayed); -registerSingleton(IChatArtifactsService, ChatArtifactsService, InstantiationType.Delayed); -registerSingleton(IChatOutputRendererService, ChatOutputRendererService, InstantiationType.Delayed); -registerSingleton(IChatLayoutService, ChatLayoutService, InstantiationType.Delayed); -registerSingleton(IPlanReviewFeedbackService, PlanReviewFeedbackService, InstantiationType.Delayed); -registerSingleton(IChatTipService, ChatTipService, InstantiationType.Delayed); -registerSingleton(IChatDebugService, ChatDebugServiceImpl, InstantiationType.Delayed); -registerSingleton(IChatImageCarouselService, ChatImageCarouselService, InstantiationType.Delayed); - -ChatWidget.CONTRIBS.push(ChatDynamicVariableModel); +registerAction2(ForkConversationAction); diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts new file mode 100644 index 0000000000000..74a6b479d2fc0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -0,0 +1,2406 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { autorun, observableFromEvent } from '../../../../base/common/observable.js'; +import { isMacintosh } from '../../../../base/common/platform.js'; +import { PolicyCategory } from '../../../../base/common/policy.js'; +import { AgentHostAhpJsonlLoggingSettingId, AgentHostClaudeAgentSdkPathSettingId, AgentHostCustomTerminalToolEnabledSettingId, AgentHostEnabledSettingId, AgentHostIpcLoggingSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOutfileSettingId } from '../../../../platform/agentHost/common/agentService.js'; +import { AgentNetworkFilterService, IAgentNetworkFilterService } from '../../../../platform/networkFilter/common/networkFilterService.js'; +import { AgentNetworkDomainSettingId } from '../../../../platform/networkFilter/common/settings.js'; +import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../platform/sandbox/common/settings.js'; +import { registerEditorFeature } from '../../../../editor/common/editorFeatures.js'; +import * as nls from '../../../../nls.js'; +import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; +import { Extensions as ConfigurationExtensions, ConfigurationScope, IConfigurationNode, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { McpAccessValue, McpAutoStartValue, mcpAccessConfig, mcpAutoStartConfig, mcpGalleryServiceEnablementConfig, mcpGalleryServiceUrlConfig, mcpAppsEnabledConfig } from '../../../../platform/mcp/common/mcpManagement.js'; +import product from '../../../../platform/product/common/product.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/editor.js'; +import { type ConfigurationKeyValuePairs, Extensions, IConfigurationMigrationRegistry } from '../../../common/configuration.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; +import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor.js'; +import { IWorkbenchAssignmentService } from '../../../services/assignment/common/assignmentService.js'; +import { ChatEntitlement, IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { IEditorResolverService, RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js'; +import { IPathService } from '../../../services/path/common/pathService.js'; +import { IViewsService } from '../../../services/views/common/viewsService.js'; +import { AddConfigurationType, AssistedTypes } from '../../mcp/browser/mcpCommandsAddConfiguration.js'; +import { allDiscoverySources, discoverySourceSettingsLabel, McpCollisionBehavior, mcpDiscoverySection, mcpServerCollisionBehaviorSection, mcpServerSamplingSection } from '../../mcp/common/mcpConfiguration.js'; +import { ChatAgentNameService, ChatAgentService, IChatAgentNameService, IChatAgentService } from '../common/participants/chatAgents.js'; +import { CodeMapperService, ICodeMapperService } from '../common/editing/chatCodeMapperService.js'; +import '../common/widget/chatColors.js'; +import { IChatEditingService } from '../common/editing/chatEditingService.js'; +import { IChatLayoutService } from '../common/widget/chatLayoutService.js'; +import { ChatModeService, IChatMode, IChatModeService, IChatModes } from '../common/chatModes.js'; +import { ChatResponseResourceFileSystemProvider, ChatResponseResourceWorkbenchContribution, IChatResponseResourceFileSystemProvider } from '../common/widget/chatResponseResourceFileSystemProvider.js'; +import { IChatService } from '../common/chatService/chatService.js'; +import { ChatService } from '../common/chatService/chatServiceImpl.js'; +import { IChatSessionsService } from '../common/chatSessionsService.js'; +import { ChatSlashCommandService, IChatSlashCommandService } from '../common/participants/chatSlashCommands.js'; +import { ChatArtifactsService, IChatArtifactsService } from '../common/tools/chatArtifactsService.js'; +import { ChatTodoListService, IChatTodoListService } from '../common/tools/chatTodoListService.js'; +import { ChatTransferService, IChatTransferService } from '../common/model/chatTransferService.js'; +import { IChatVariablesService } from '../common/attachments/chatVariables.js'; +import { ChatWidgetHistoryService, IChatWidgetHistoryService } from '../common/widget/chatWidgetHistoryService.js'; +import { ChatAgentLocation, ChatConfiguration, ChatNotificationMode, ChatPermissionLevel } from '../common/constants.js'; +import { ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService } from '../common/ignoredFiles.js'; +import { ILanguageModelsService, LanguageModelsService } from '../common/languageModels.js'; +import { ILanguageModelStatsService, LanguageModelStatsService } from '../common/languageModelStats.js'; +import { ILanguageModelToolsConfirmationService } from '../common/tools/languageModelToolsConfirmationService.js'; +import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; +import { ChatToolRiskAssessmentService, IChatToolRiskAssessmentService } from './tools/chatToolRiskAssessmentService.js'; +import { agentPluginDiscoveryRegistry, IAgentPluginService } from '../common/plugins/agentPluginService.js'; +import { ChatPromptFilesExtensionPointHandler } from '../common/promptSyntax/chatPromptFilesContribution.js'; +import { isTildePath, PromptsConfig } from '../common/promptSyntax/config/config.js'; +import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION, DEFAULT_SKILL_SOURCE_FOLDERS, AGENTS_SOURCE_FOLDER, AGENT_FILE_EXTENSION, SKILL_FILENAME, CLAUDE_AGENTS_SOURCE_FOLDER, DEFAULT_HOOK_FILE_PATHS, DEFAULT_INSTRUCTIONS_SOURCE_FOLDERS, COPILOT_USER_AGENTS_SOURCE_FOLDER } from '../common/promptSyntax/config/promptFileLocations.js'; +import { PromptLanguageFeaturesProvider } from './promptSyntax/promptFileContributions.js'; +import { AGENT_DOCUMENTATION_URL, INSTRUCTIONS_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, SKILL_DOCUMENTATION_URL, HOOK_DOCUMENTATION_URL, PromptsType, PromptFileSource } from '../common/promptSyntax/promptTypes.js'; +import { hookFileSchema, HOOK_SCHEMA_URI } from '../common/promptSyntax/hookSchema.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js'; +import { IPromptsService } from '../common/promptSyntax/service/promptsService.js'; +import { PromptsService } from '../common/promptSyntax/service/promptsServiceImpl.js'; +import { LanguageModelToolsExtensionPointHandler } from '../common/tools/languageModelToolsContribution.js'; +import './telemetry/chatModelCountTelemetry.js'; +import { BuiltinToolsContribution } from '../common/tools/builtinTools/tools.js'; +import { RenameToolContribution } from './tools/renameTool.js'; +import { UsagesToolContribution } from './tools/usagesTool.js'; +import { IVoiceChatService, VoiceChatService } from '../common/voiceChatService.js'; +import { registerChatAccessibilityActions } from './actions/chatAccessibilityActions.js'; +import { AgentChatAccessibilityHelp, EditsChatAccessibilityHelp, PanelChatAccessibilityHelp, QuickChatAccessibilityHelp } from './actions/chatAccessibilityHelp.js'; +import { ModeOpenChatGlobalAction, registerChatActions } from './actions/chatActions.js'; +import { CodeBlockActionRendering, registerChatCodeBlockActions, registerChatCodeCompareBlockActions } from './actions/chatCodeblockActions.js'; +import { ChatContextContributions } from './actions/chatContext.js'; +import { registerChatContextActions } from './actions/chatContextActions.js'; +import { ChatCopyActionRendering, registerChatCopyActions } from './actions/chatCopyActions.js'; +import { registerChatDeveloperActions } from './actions/chatDeveloperActions.js'; +import { registerChatExecuteActions } from './actions/chatExecuteActions.js'; +import { registerChatFileTreeActions } from './actions/chatFileTreeActions.js'; +import { ChatGettingStartedContribution } from './actions/chatGettingStarted.js'; +import { registerChatExportActions } from './actions/chatImportExport.js'; +import { registerLanguageModelActions } from './actions/chatLanguageModelActions.js'; +import { registerChatPluginActions } from './actions/chatPluginActions.js'; +import { registerMoveActions } from './actions/chatMoveActions.js'; +import { registerNewChatActions } from './actions/chatNewActions.js'; +import { registerChatPromptNavigationActions } from './actions/chatPromptNavigationActions.js'; +import { registerChatQueueActions } from './actions/chatQueueActions.js'; +import { registerQuickChatActions } from './actions/chatQuickInputActions.js'; +import { ChatAgentRecommendation } from './actions/chatAgentRecommendationActions.js'; +import { registerChatTitleActions } from './actions/chatTitleActions.js'; +import { registerChatElicitationActions } from './actions/chatElicitationActions.js'; +import { registerChatToolActions } from './actions/chatToolActions.js'; +import { ChatTransferContribution } from './actions/chatTransfer.js'; +import { registerChatOpenAgentDebugPanelAction } from './actions/chatOpenAgentDebugPanelAction.js'; +import { IChatDebugService } from '../common/chatDebugService.js'; +import { ChatDebugServiceImpl } from '../common/chatDebugServiceImpl.js'; +import { ChatDebugEditor } from './chatDebug/chatDebugEditor.js'; +import { PromptsDebugContribution } from './promptsDebugContribution.js'; +import { ChatDebugEditorInput, ChatDebugEditorInputSerializer } from './chatDebug/chatDebugEditorInput.js'; +import './agentSessions/agentSessions.contribution.js'; + +import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; + +import { ChatViewId, IChatAccessibilityService, IChatCodeBlockContextProviderService, IChatWidgetService, IQuickChatService, isIChatResourceViewContext, isIChatViewViewContext } from './chat.js'; +import { ChatAccessibilityService } from './accessibility/chatAccessibilityService.js'; +import './attachments/chatAttachmentModel.js'; +import './widget/input/chatInputNotificationService.js'; +import { ChatAttachmentResolveService, IChatAttachmentResolveService } from './attachments/chatAttachmentResolveService.js'; +import { ChatAttachmentWidgetRegistry, IChatAttachmentWidgetRegistry } from './attachments/chatAttachmentWidgetRegistry.js'; +import { ChatMarkdownAnchorService, IChatMarkdownAnchorService } from './widget/chatContentParts/chatMarkdownAnchorService.js'; +import { ChatContextPickService, IChatContextPickService } from './attachments/chatContextPickService.js'; +import { ChatInputBoxContentProvider } from './widget/input/editor/chatEditorInputContentProvider.js'; +import { ChatEditingEditorAccessibility } from './chatEditing/chatEditingEditorAccessibility.js'; +import { registerChatEditorActions } from './chatEditing/chatEditingEditorActions.js'; +import { ChatEditingEditorContextKeys } from './chatEditing/chatEditingEditorContextKeys.js'; +import { ChatEditingEditorOverlay } from './chatEditing/chatEditingEditorOverlay.js'; +import { ChatEditingService } from './chatEditing/chatEditingServiceImpl.js'; +import { ChatEditingNotebookFileSystemProviderContrib } from './chatEditing/notebook/chatEditingNotebookFileSystemProvider.js'; +import { ChatEditor, IChatEditorOptions } from './widgetHosts/editor/chatEditor.js'; +import { ChatEditorInput, ChatEditorInputSerializer } from './widgetHosts/editor/chatEditorInput.js'; +import { ChatLayoutService } from './widget/chatLayoutService.js'; +import { ChatLanguageModelsDataContribution, LanguageModelsConfigurationService } from './languageModelsConfigurationService.js'; +import './chatManagement/chatManagement.contribution.js'; +import './aiCustomization/aiCustomizationWorkspaceService.js'; +import './aiCustomization/customizationHarnessService.js'; +import './aiCustomization/aiCustomizationManagement.contribution.js'; +import './aiCustomization/aiCustomizationItemsModel.js'; + +import { ChatOutputRendererService, IChatOutputRendererService } from './chatOutputItemRenderer.js'; +import { ChatCompatibilityNotifier, ChatExtensionPointHandler } from './chatParticipant.contribution.js'; +import { ChatPasteProvidersFeature } from './widget/input/editor/chatPasteProviders.js'; +import { QuickChatService } from './widgetHosts/chatQuick.js'; +import { ChatResponseAccessibleView } from './accessibility/chatResponseAccessibleView.js'; +import { ChatTerminalOutputAccessibleView } from './accessibility/chatTerminalOutputAccessibleView.js'; +import { ChatSetupContribution, ChatTeardownContribution } from './chatSetup/chatSetupContributions.js'; +import { HasByokModelsContribution } from './hasByokModelsContribution.js'; +import { ChatStatusBarEntry } from './chatStatus/chatStatusEntry.js'; +import { ChatVariablesService } from './attachments/chatVariables.js'; +import { ChatWidget } from './widget/chatWidget.js'; +import { ChatCodeBlockContextProviderService } from './codeBlockContextProviderService.js'; +import { ChatDynamicVariableModel } from './attachments/chatDynamicVariables.js'; +import { ChatImplicitContextContribution } from './attachments/chatImplicitContext.js'; +import './widget/input/editor/chatInputCompletions.js'; +import './widget/input/editor/agentHostInputCompletions.js'; +import './widget/input/editor/chatInputEditorContrib.js'; +import './widget/input/editor/chatInputEditorHover.js'; +import { LanguageModelToolsConfirmationService } from './tools/languageModelToolsConfirmationService.js'; +import { LanguageModelToolsService, globalAutoApproveDescription } from './tools/languageModelToolsService.js'; +import { IToolResultCompressor } from '../common/tools/toolResultCompressor.js'; +import { ToolResultCompressorService } from './tools/toolResultCompressorService.js'; +import { AgentPluginService, ConfiguredAgentPluginDiscovery, CopilotCliAgentPluginDiscovery, ExtensionAgentPluginDiscovery, MarketplaceAgentPluginDiscovery } from '../common/plugins/agentPluginServiceImpl.js'; +import { IAgentPluginRepositoryService } from '../common/plugins/agentPluginRepositoryService.js'; +import { IPluginInstallService } from '../common/plugins/pluginInstallService.js'; +import { IPluginMarketplaceService, PluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js'; +import { WorkspacePluginSettingsService, IWorkspacePluginSettingsService } from '../common/plugins/workspacePluginSettingsService.js'; +import { AgentPluginRecommendations } from './claudePluginRecommendations.js'; +import { AgentPluginEditor } from './agentPluginEditor/agentPluginEditor.js'; +import { AgentPluginEditorInput } from './agentPluginEditor/agentPluginEditorInput.js'; +import { AgentPluginRepositoryService } from './agentPluginRepositoryService.js'; +import { BrowserPluginGitCommandService } from './pluginGitCommandService.js'; +import { IPluginGitService } from '../common/plugins/pluginGitService.js'; +import { PluginInstallService } from './pluginInstallService.js'; +import { PluginAutoUpdate } from './pluginAutoUpdate.js'; +import './promptSyntax/promptCodingAgentActionContribution.js'; +import './promptSyntax/promptToolsCodeLensProvider.js'; +import { ChatSessionOptionSlashCommandsContribution, ChatSlashCommandsContribution } from './chatSlashCommands.js'; +import './planReviewFeedback/planReviewFeedbackEditorContribution.js'; +import { registerPlanReviewFeedbackEditorActions } from './planReviewFeedback/planReviewFeedbackEditorActions.js'; +import { IPlanReviewFeedbackService, PlanReviewFeedbackService } from './planReviewFeedback/planReviewFeedbackService.js'; +import { PluginUrlHandler } from './pluginUrlHandler.js'; +import { PromptUrlHandler } from './promptSyntax/promptUrlHandler.js'; +import { ConfigureToolSets, UserToolSetsContributions } from './tools/toolSetsContribution.js'; +import { ChatViewsWelcomeHandler } from './viewsWelcome/chatViewsWelcomeHandler.js'; +import { ChatWidgetService } from './widget/chatWidgetService.js'; +import { ILanguageModelsConfigurationService } from '../common/languageModelsConfiguration.js'; +import { ChatWindowNotifier } from './chatWindowNotifier.js'; +import { ChatRepoInfoContribution } from './chatRepoInfo.js'; +import { VALID_PROMPT_FOLDER_PATTERN } from '../common/promptSyntax/utils/promptFilesLocator.js'; +import { ChatTipService, IChatTipService } from './chatTipService.js'; +import { ChatQueuePickerRendering } from './widget/input/chatQueuePickerActionItem.js'; +import { ExploreAgentDefaultModel } from './exploreAgentDefaultModel.js'; +import { PlanAgentDefaultModel } from './planAgentDefaultModel.js'; +import { UtilityModelContribution, UtilitySmallModelContribution } from './utilityModelContribution.js'; +import { ChatImageCarouselService, IChatImageCarouselService } from './chatImageCarouselService.js'; +import { browserChatToolReferenceNames } from '../../browserView/common/browserChatToolReferenceNames.js'; + +CommandsRegistry.registerCommand('_chat.notifyQuestionCarouselAnswer', (accessor: ServicesAccessor, resolveId: string, answers?: import('../common/chatService/chatService.js').IChatQuestionAnswers) => { + accessor.get(IChatService).notifyQuestionCarouselAnswer('', resolveId, answers); +}); + +const toolReferenceNameEnumValues: string[] = []; +const toolReferenceNameEnumDescriptions: string[] = []; + +// Register JSON schema for hook files +const jsonContributionRegistry = Registry.as(JSONExtensions.JSONContribution); +jsonContributionRegistry.registerSchema(HOOK_SCHEMA_URI, hookFileSchema); + +// Register configuration +const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); +configurationRegistry.registerConfiguration({ + id: 'chatSidebar', + title: nls.localize('interactiveSessionConfigurationTitle', "Chat"), + type: 'object', + properties: { + 'chat.experimentalSessionsWindowOverride': { + type: 'boolean', + description: nls.localize('chat.experimentalSessionsWindowOverride', "When true, enables sessions-window-specific behavior for extensions."), + default: false, + tags: ['experimental'], + agentsWindow: { default: true }, + }, + 'chat.fontSize': { + type: 'number', + description: nls.localize('chat.fontSize', "Controls the font size in pixels in chat messages."), + default: 13, + minimum: 6, + maximum: 100 + }, + 'chat.fontFamily': { + type: 'string', + description: nls.localize('chat.fontFamily', "Controls the font family in chat messages."), + default: 'default' + }, + 'chat.editor.fontSize': { + type: 'number', + description: nls.localize('interactiveSession.editor.fontSize', "Controls the font size in pixels in chat codeblocks."), + default: isMacintosh ? 12 : 14, + }, + 'chat.editor.fontFamily': { + type: 'string', + description: nls.localize('interactiveSession.editor.fontFamily', "Controls the font family in chat codeblocks."), + default: 'default' + }, + 'chat.editor.fontWeight': { + type: 'string', + description: nls.localize('interactiveSession.editor.fontWeight', "Controls the font weight in chat codeblocks."), + default: 'default' + }, + 'chat.editor.wordWrap': { + type: 'string', + description: nls.localize('interactiveSession.editor.wordWrap', "Controls whether lines should wrap in chat codeblocks."), + default: 'off', + enum: ['on', 'off'] + }, + 'chat.editor.lineHeight': { + type: 'number', + description: nls.localize('interactiveSession.editor.lineHeight', "Controls the line height in pixels in chat codeblocks. Use 0 to compute the line height from the font size."), + default: 0 + }, + [ChatConfiguration.AgentStatusEnabled]: { + type: 'string', + enum: ['hidden', 'badge', 'compact'], + enumDescriptions: [ + nls.localize('chat.agentsControl.hidden', "The agent status indicator is hidden from the title bar."), + nls.localize('chat.agentsControl.badge', "Shows the agent status as a badge next to the command center."), + nls.localize('chat.agentsControl.compact', "Replaces the command center search box with a compact agent status indicator and unified chat widget."), + ], + markdownDescription: nls.localize('chat.agentsControl.enabled', "Controls how the 'Agent Status' indicator appears in the title bar command center. When set to `hidden`, the indicator is not shown. Other values show the indicator and automatically enable {0}. The unread and in-progress session indicators require {1} to be enabled.", '`#window.commandCenter#`', '`#chat.viewSessions.enabled#`'), + default: 'compact', + tags: ['experimental'] + }, + [ChatConfiguration.UnifiedAgentsBar]: { + type: 'boolean', + markdownDescription: nls.localize('chat.unifiedAgentsBar.enabled', "Replaces the command center search box with a unified chat and search widget."), + default: false, + tags: ['experimental'] + }, + [ChatConfiguration.AgentSessionProjectionEnabled]: { + type: 'boolean', + markdownDescription: nls.localize('chat.agentSessionProjection.enabled', "Controls whether Agent Session Projection mode is enabled for reviewing agent sessions in a focused workspace."), + default: false, + tags: ['experimental'], + }, + 'chat.implicitContext.enabled': { + type: 'object', + description: nls.localize('chat.implicitContext.enabled.1', "Enables automatically using the active editor as chat context for specified chat locations."), + additionalProperties: { + type: 'string', + enum: ['never', 'first', 'always'], + description: nls.localize('chat.implicitContext.value', "The value for the implicit context."), + enumDescriptions: [ + nls.localize('chat.implicitContext.value.never', "Implicit context is never enabled."), + nls.localize('chat.implicitContext.value.first', "Implicit context is enabled for the first interaction."), + nls.localize('chat.implicitContext.value.always', "Implicit context is always enabled.") + ] + }, + default: { + 'panel': 'always', + }, + tags: ['experimental'], + experiment: { + mode: 'startup' + }, + agentsWindow: { default: { 'panel': 'never' } }, + }, + 'chat.implicitContext.suggestedContext': { + type: 'boolean', + markdownDescription: nls.localize('chat.implicitContext.suggestedContext', "Controls whether the new implicit context flow is shown. In Ask and Edit modes, the context will automatically be included. When using an agent, context will be suggested as an attachment. Selections are always included as context."), + default: true, + agentsWindow: { default: false }, + }, + 'chat.editing.autoAcceptDelay': { + type: 'number', + markdownDescription: nls.localize('chat.editing.autoAcceptDelay', "Delay after which changes made by chat are automatically accepted. Values are in seconds, `0` means disabled and `100` seconds is the maximum."), + default: 0, + minimum: 0, + maximum: 100 + }, + 'chat.editing.confirmEditRequestRemoval': { + type: 'boolean', + scope: ConfigurationScope.APPLICATION, + markdownDescription: nls.localize('chat.editing.confirmEditRequestRemoval', "Whether to show a confirmation before removing a request and its associated edits."), + default: true, + }, + 'chat.editing.confirmEditRequestRetry': { + type: 'boolean', + scope: ConfigurationScope.APPLICATION, + markdownDescription: nls.localize('chat.editing.confirmEditRequestRetry', "Whether to show a confirmation before retrying a request and its associated edits."), + default: true, + }, + 'chat.editing.explainChanges.enabled': { + type: 'boolean', + markdownDescription: nls.localize('chat.editing.explainChanges.enabled', "Controls whether the Explain button in the Chat panel and the Explain Changes context menu in the SCM view are shown. This is an experimental feature."), + default: false, + tags: ['experimental'], + experiment: { + mode: 'auto' + } + }, + [ChatConfiguration.RevealNextChangeOnResolve]: { + type: 'boolean', + markdownDescription: nls.localize('chat.editing.revealNextChangeOnResolve', "Controls whether the editor automatically reveals the next change after keeping or undoing a chat edit."), + default: true, + }, + 'chat.tips.enabled': { + type: 'boolean', + scope: ConfigurationScope.APPLICATION, + description: nls.localize('chat.tips.enabled', "Controls whether tips are shown above user messages in chat. New tips are added frequently, so this is a helpful way to stay up to date with the latest features."), + default: true, + }, + 'chat.upvoteAnimation': { + type: 'string', + enum: ['off', 'confetti', 'floatingThumbs', 'pulseWave', 'radiantLines'], + enumDescriptions: [ + nls.localize('chat.upvoteAnimation.off', "No animation is shown."), + nls.localize('chat.upvoteAnimation.confetti', "Shows a confetti burst animation around the thumbs up button."), + nls.localize('chat.upvoteAnimation.floatingThumbs', "Shows floating thumbs up icons rising from the button."), + nls.localize('chat.upvoteAnimation.pulseWave', "Shows expanding pulse rings from the button."), + nls.localize('chat.upvoteAnimation.radiantLines', "Shows radiant lines emanating from the button."), + ], + description: nls.localize('chat.upvoteAnimation', "Controls whether an animation is shown when clicking the thumbs up button on a chat response."), + default: 'floatingThumbs', + }, + 'chat.experimental.detectParticipant.enabled': { + type: 'boolean', + deprecationMessage: nls.localize('chat.experimental.detectParticipant.enabled.deprecated', "This setting is deprecated. Please use `chat.detectParticipant.enabled` instead."), + description: nls.localize('chat.experimental.detectParticipant.enabled', "Enables chat participant autodetection for panel chat."), + default: null + }, + [ChatConfiguration.IncrementalRendering]: { + type: 'boolean', + description: nls.localize('chat.experimental.incrementalRendering.enabled', "Enables incremental rendering with optional block-level animation when streaming chat responses."), + default: false, + tags: ['experimental'], + }, + [ChatConfiguration.IncrementalRenderingStyle]: { + type: 'string', + enum: ['none', 'fade', 'rise', 'blur', 'scale', 'slide', 'reveal'], + enumDescriptions: [ + nls.localize('chat.experimental.incrementalRendering.animationStyle.none', "No animation. Content appears instantly."), + nls.localize('chat.experimental.incrementalRendering.animationStyle.fade', "Simple opacity fade from 0 to 1."), + nls.localize('chat.experimental.incrementalRendering.animationStyle.rise', "Content fades in while rising upward."), + nls.localize('chat.experimental.incrementalRendering.animationStyle.blur', "Content fades in from a blurred state."), + nls.localize('chat.experimental.incrementalRendering.animationStyle.scale', "Content scales up from slightly smaller."), + nls.localize('chat.experimental.incrementalRendering.animationStyle.slide', "Content slides in from the left."), + nls.localize('chat.experimental.incrementalRendering.animationStyle.reveal', "Content reveals top-to-bottom with a soft gradient edge."), + ], + description: nls.localize('chat.experimental.incrementalRendering.animationStyle', "Controls the animation style for incremental rendering."), + default: 'fade', + tags: ['experimental'], + }, + [ChatConfiguration.IncrementalRenderingBuffering]: { + type: 'string', + enum: ['off', 'word', 'paragraph'], + enumDescriptions: [ + nls.localize('chat.experimental.incrementalRendering.buffering.off', "Renders content immediately as tokens arrive."), + nls.localize('chat.experimental.incrementalRendering.buffering.word', "Reveals content word by word."), + nls.localize('chat.experimental.incrementalRendering.buffering.paragraph', "Buffers content until a paragraph break before rendering."), + ], + description: nls.localize('chat.experimental.incrementalRendering.buffering', "Controls how content is buffered before rendering during incremental rendering. Lower buffering levels render faster but may show incomplete sentences or partially formed markdown."), + default: 'word', + tags: ['experimental'], + }, + 'chat.detectParticipant.enabled': { + type: 'boolean', + description: nls.localize('chat.detectParticipant.enabled', "Enables chat participant autodetection for panel chat."), + default: true + }, + [ChatConfiguration.InlineReferencesStyle]: { + type: 'string', + enum: ['box', 'link'], + enumDescriptions: [ + nls.localize('chat.inlineReferences.style.box', "Display file and symbol references as boxed widgets with icons."), + nls.localize('chat.inlineReferences.style.link', "Display file and symbol references as simple blue links without icons.") + ], + description: nls.localize('chat.inlineReferences.style', "Controls how file and symbol references are displayed in chat messages."), + default: 'box' + }, + [ChatConfiguration.EditorAssociations]: { + type: 'object', + markdownDescription: nls.localize('chat.editorAssociations', "Configure [glob patterns](https://aka.ms/vscode-glob-patterns) to editors for opening files from chat (for example `\"*.md\": \"vscode.markdown.preview.editor\"`)."), + additionalProperties: { + type: 'string' + }, + default: { + } + }, + [ChatConfiguration.NotifyWindowOnConfirmation]: { + type: 'string', + enum: ['off', 'windowNotFocused', 'always'], + enumDescriptions: [ + nls.localize('chat.notifyWindowOnConfirmation.off', "Never show OS notifications for confirmations."), + nls.localize('chat.notifyWindowOnConfirmation.windowNotFocused', "Show OS notifications for confirmations when the window is not focused."), + nls.localize('chat.notifyWindowOnConfirmation.always', "Always show OS notifications for confirmations, even when the window is focused."), + ], + description: nls.localize('chat.notifyWindowOnConfirmation', "Controls whether a chat session should present the user with an OS notification when a confirmation or question needs input. This includes a window badge as well as notification toast."), + default: 'windowNotFocused', + }, + [ChatConfiguration.AutoReply]: { + default: false, + markdownDescription: nls.localize('chat.autoReply.description', "Automatically skip question carousels by telling the agent that the user is not available and to use its best judgment. This is an advanced setting and can lead to unintended choices or actions based on incomplete context."), + type: 'boolean', + scope: ConfigurationScope.APPLICATION_MACHINE, + tags: ['experimental', 'advanced'], + }, + [ChatConfiguration.AutopilotEnabled]: { + type: 'boolean', + markdownDescription: nls.localize('chat.autopilot.enabled', "Controls whether the Autopilot mode is available in the permissions picker. When enabled, Autopilot auto-approves all tool calls and continues until the task is done."), + default: true, + tags: ['experimental'], + }, + [ChatConfiguration.PlanReviewInlineEditorEnabled]: { + type: 'boolean', + markdownDescription: nls.localize('chat.planReview.inlineEditor.enabled', "When enabled, the plan review widget mounts an editor inline, as opposed to in a separate editor tab."), + default: true, + }, + [ChatConfiguration.DefaultPermissionLevel]: { + type: 'string', + enum: [ChatPermissionLevel.Default, ChatPermissionLevel.AutoApprove, ChatPermissionLevel.Autopilot], + enumItemLabels: [ + nls.localize('chat.permissions.default.default.label', "Default Approvals"), + nls.localize('chat.permissions.default.autoApprove.label', "Bypass Approvals"), + nls.localize('chat.permissions.default.autopilot.label', "Autopilot (Preview)"), + ], + enumDescriptions: [ + nls.localize('chat.permissions.default.default.description', "Start new chat sessions with Default Approvals."), + nls.localize('chat.permissions.default.autoApprove.description', "Start new chat sessions in Bypass Approvals mode."), + nls.localize('chat.permissions.default.autopilot.description', "Start new chat sessions in Autopilot mode."), + ], + description: nls.localize('chat.permissions.default.settingDescription', "Controls the default permissions picker mode for new chat sessions. You can still change the permission mode per session, and each session remembers the permission mode that was used. If enterprise policy disables auto approval, new sessions use Default Approvals."), + default: ChatPermissionLevel.Default, + tags: ['experimental'], + }, + [ChatConfiguration.GlobalAutoApprove]: { + default: false, + markdownDescription: globalAutoApproveDescription.value, + type: 'boolean', + scope: ConfigurationScope.APPLICATION_MACHINE, + tags: ['experimental'], + policy: { + name: 'ChatToolsAutoApprove', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.99', + value: (policyData) => policyData.chat_preview_features_enabled === false ? false : undefined, + localization: { + description: { + key: 'autoApprove3.description', + value: nls.localize('autoApprove3.description', 'Global auto approve also known as "YOLO mode" disables manual approval completely for all tools in all workspaces, allowing the agent to act fully autonomously. This is extremely dangerous and is *never* recommended, even containerized environments like Codespaces and Dev Containers have user keys forwarded into the container that could be compromised.\n\nThis feature disables critical security protections and makes it much easier for an attacker to compromise the machine.\n\nNote: This setting only controls tool approval and does not prevent the agent from asking questions. To automatically answer agent questions, use the `#chat.autoReply#` setting.') + } + }, + } + }, + [ChatConfiguration.SessionSyncEnabled]: { + default: false, + markdownDescription: nls.localize('chat.sessionSync.enabled', "Enable session sync to GitHub.com. When enabled, Copilot session data is synced to your GitHub account for cross-device access and richer insights. Requires `#github.copilot.chat.localIndex.enabled#` to also be enabled."), + type: 'boolean', + tags: ['experimental', 'advanced'], + experiment: { + mode: 'auto' + }, + policy: { + name: 'CopilotSessionSync', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.121', + value: (policyData) => policyData.cloud_session_storage_enabled === false ? false : undefined, + localization: { + description: { + key: 'chat.sessionSync.enabled.policy', + value: nls.localize('chat.sessionSync.enabled.policy', "Enable session sync to GitHub.com for cross-device Copilot session history. When disabled by organization policy, session data is kept local only."), + } + }, + } + }, + [ChatConfiguration.SessionSyncExcludeRepositories]: { + type: 'array', + items: { type: 'string' }, + default: [], + markdownDescription: nls.localize('chat.sessionSync.excludeRepositories', "Repository patterns to exclude from session sync. Use exact `owner/repo` names or glob patterns like `my-org/*`. Sessions from matching repositories will only be stored locally."), + tags: ['experimental', 'advanced'], + }, + [ChatConfiguration.AutoApproveEdits]: { + default: { + '**/*': 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, // yarn.lock, bun.lock, etc. + '**/*-lock.{yaml,json}': false, // pnpm-lock.yaml, package-lock.json + }, + markdownDescription: nls.localize('chat.tools.autoApprove.edits', "Controls whether edits made by the agent are automatically approved. The default is to approve all edits except those made to certain files which have the potential to cause immediate unintended side-effects, such as `**/.vscode/*.json`.\n\nSet to `true` to automatically approve edits to matching files, `false` to always require explicit approval. The last pattern matching a given file will determine whether the edit is automatically approved."), + type: 'object', + additionalProperties: { + type: 'boolean', + } + }, + [ChatConfiguration.AutoApprovedUrls]: { + default: { + 'https://code.visualstudio.com': true, + 'https://github.com/microsoft/vscode/wiki/*': true, + }, + markdownDescription: nls.localize('chat.tools.fetchPage.approvedUrls', "Controls which URLs are automatically approved when requested by chat tools. Keys are URL patterns and values can be `true` to approve both requests and responses, `false` to deny, or an object with `approveRequest` and `approveResponse` properties for granular control.\n\nExamples:\n- `\"https://example.com\": true` - Approve all requests to example.com\n- `\"https://*.example.com\": true` - Approve all requests to any subdomain of example.com\n- `\"https://example.com/api/*\": { \"approveRequest\": true, \"approveResponse\": false }` - Approve requests but not responses for example.com/api paths"), + type: 'object', + additionalProperties: { + oneOf: [ + { type: 'boolean' }, + { + type: 'object', + properties: { + approveRequest: { type: 'boolean' }, + approveResponse: { type: 'boolean' } + } + } + ] + } + }, + [ChatConfiguration.EligibleForAutoApproval]: { + default: {}, + markdownDescription: nls.localize('chat.tools.eligibleForAutoApproval', 'Controls which tools are eligible for automatic approval. Tools set to \'false\' will always present a confirmation and will never offer the option to auto-approve. The default behavior (or setting a tool to \'true\') may result in the tool offering auto-approval options.'), + type: 'object', + propertyNames: { + enum: toolReferenceNameEnumValues, + enumDescriptions: toolReferenceNameEnumDescriptions, + }, + additionalProperties: { + type: 'boolean', + }, + examples: [ + { + 'fetch': false, + 'runTask': false + } + ], + policy: { + name: 'ChatToolsEligibleForAutoApproval', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.107', + localization: { + description: { + key: 'chat.tools.eligibleForAutoApproval', + value: nls.localize('chat.tools.eligibleForAutoApproval', 'Controls which tools are eligible for automatic approval. Tools set to \'false\' will always present a confirmation and will never offer the option to auto-approve. The default behavior (or setting a tool to \'true\') may result in the tool offering auto-approval options.') + } + }, + } + }, + 'chat.sendElementsToChat.attachImages': { + default: true, + markdownDescription: nls.localize('chat.sendElementsToChat.attachImages', "Controls whether a screenshot of the selected element will be added to the chat."), + type: 'boolean', + tags: ['experimental'] + }, + [ChatConfiguration.ArtifactsEnabled]: { + default: false, + description: nls.localize('chat.artifacts.enabled', "Controls whether the artifacts view is available in chat."), + type: 'boolean', + tags: ['experimental'] + }, + [ChatConfiguration.ArtifactsRulesByMimeType]: { + default: { + 'image/*': { groupName: 'Screenshots', onlyShowGroup: true } + }, + description: nls.localize('chat.artifacts.rules.byMimeType', "Rules for extracting artifacts from tool results by MIME type. Maps MIME type patterns (e.g. 'image/*') to group configuration."), + type: 'object', + additionalProperties: { + type: 'object', + properties: { + groupName: { type: 'string', description: nls.localize('chat.artifacts.rules.groupName', "Display name for the artifact group.") }, + onlyShowGroup: { type: 'boolean', description: nls.localize('chat.artifacts.rules.onlyShowGroup', "When true, show only the group header instead of individual items.") } + }, + required: ['groupName'] + }, + tags: ['experimental'] + }, + [ChatConfiguration.ArtifactsRulesByFilePath]: { + default: { + '**/*plan*.md': { groupName: 'Plans' } + }, + description: nls.localize('chat.artifacts.rules.byFilePath', "Rules for extracting artifacts from written files by file path pattern. Maps glob patterns to group configuration."), + type: 'object', + additionalProperties: { + type: 'object', + properties: { + groupName: { type: 'string', description: nls.localize('chat.artifacts.rules.byFilePath.groupName', "Display name for the artifact group.") }, + onlyShowGroup: { type: 'boolean', description: nls.localize('chat.artifacts.rules.byFilePath.onlyShowGroup', "When true, show only the group header instead of individual items.") } + }, + required: ['groupName'] + }, + tags: ['experimental'] + }, + [ChatConfiguration.ArtifactsRulesByMemoryFilePath]: { + default: { + '**/*plan*.md': { groupName: 'Plans' } + }, + description: nls.localize('chat.artifacts.rules.byMemoryFilePath', "Rules for extracting artifacts from memory tool calls by memory file path pattern. Maps glob patterns to group configuration."), + type: 'object', + additionalProperties: { + type: 'object', + properties: { + groupName: { type: 'string', description: nls.localize('chat.artifacts.rules.byMemoryFilePath.groupName', "Display name for the artifact group.") }, + onlyShowGroup: { type: 'boolean', description: nls.localize('chat.artifacts.rules.byMemoryFilePath.onlyShowGroup', "When true, show only the group header instead of individual items.") } + }, + required: ['groupName'] + }, + tags: ['experimental'] + }, + 'chat.undoRequests.restoreInput': { + default: true, + markdownDescription: nls.localize('chat.undoRequests.restoreInput', "Controls whether the input of the chat should be restored when an undo request is made. The input will be filled with the text of the request that was restored."), + type: 'boolean', + }, + 'chat.editRequests': { + markdownDescription: nls.localize('chat.editRequests', "Enables editing of requests in the chat. This allows you to change the request content and resubmit it to the model."), + type: 'string', + enum: ['inline', 'hover', 'input', 'none'], + default: 'inline', + }, + [ChatConfiguration.ChatViewSessionsEnabled]: { + type: 'boolean', + default: true, + description: nls.localize('chat.viewSessions.enabled', "Show chat agent sessions when chat is empty or to the side when chat view is wide enough."), + agentsWindow: { default: false }, + }, + [ChatConfiguration.ChatViewSessionsOrientation]: { + type: 'string', + enum: ['stacked', 'sideBySide'], + enumDescriptions: [ + nls.localize('chat.viewSessions.orientation.stacked', "Display chat sessions vertically stacked above the chat input unless a chat session is visible."), + nls.localize('chat.viewSessions.orientation.sideBySide', "Display chat sessions side by side if space is sufficient, otherwise fallback to stacked above the chat input unless a chat session is visible.") + ], + default: 'sideBySide', + description: nls.localize('chat.viewSessions.orientation', "Controls the orientation of the chat agent sessions view when it is shown alongside the chat."), + }, + [ChatConfiguration.ChatViewProgressBadgeEnabled]: { + type: 'boolean', + default: false, + description: nls.localize('chat.viewProgressBadge.enabled', "Show a progress badge on the chat view when an agent session is in progress that is opened in that view."), + }, + [ChatConfiguration.ChatContextUsageEnabled]: { + type: 'boolean', + default: true, + description: nls.localize('chat.contextUsage.enabled', "Show the context window usage indicator in the chat input."), + }, + [ChatConfiguration.ChatPersistentProgressEnabled]: { + type: 'boolean', + default: product.quality !== 'stable', + description: nls.localize('chat.persistentProgress.enabled', "Always show progress in chat."), + }, + [ChatConfiguration.ProgressBorder]: { + type: 'boolean', + default: true, + markdownDescription: nls.localize('chat.progressBorder.enabled', "Show an animated gradient border around the chat input while the agent is working or thinking. When enabled and reduced motion is not enabled, this overrides {0} to be off. Has no effect when reduced motion is enabled.", '`#chat.persistentProgress.enabled#`'), + }, + [ChatConfiguration.NotifyWindowOnResponseReceived]: { + type: 'string', + enum: ['off', 'windowNotFocused', 'always'], + enumDescriptions: [ + nls.localize('chat.notifyWindowOnResponseReceived.off', "Never show OS notifications for responses."), + nls.localize('chat.notifyWindowOnResponseReceived.windowNotFocused', "Show OS notifications for responses when the window is not focused."), + nls.localize('chat.notifyWindowOnResponseReceived.always', "Always show OS notifications for responses, even when the window is focused."), + ], + default: 'windowNotFocused', + description: nls.localize('chat.notifyWindowOnResponseReceived', "Controls whether a chat session should present the user with an OS notification when a response is received. This includes a window badge as well as notification toast."), + }, + 'chat.checkpoints.enabled': { + type: 'boolean', + default: true, + description: nls.localize('chat.checkpoints.enabled', "Enables checkpoints in chat. Checkpoints allow you to restore the chat to a previous state."), + }, + 'chat.checkpoints.showFileChanges': { + type: 'boolean', + description: nls.localize('chat.checkpoints.showFileChanges', "Controls whether to show chat checkpoint file changes."), + default: false + }, + [mcpAccessConfig]: { + type: 'string', + description: nls.localize('chat.mcp.access', "Controls access to installed Model Context Protocol servers."), + enum: [ + McpAccessValue.None, + McpAccessValue.Registry, + McpAccessValue.All + ], + enumDescriptions: [ + nls.localize('chat.mcp.access.none', "No access to MCP servers."), + nls.localize('chat.mcp.access.registry', "Allows access to MCP servers installed from the registry that VS Code is connected to."), + nls.localize('chat.mcp.access.any', "Allow access to any installed MCP server.") + ], + default: McpAccessValue.All, + policy: { + name: 'ChatMCP', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.99', + value: (policyData) => { + if (policyData.mcp === false) { + return McpAccessValue.None; + } + if (policyData.mcpAccess === 'registry_only') { + return McpAccessValue.Registry; + } + return undefined; + }, + localization: { + description: { + key: 'chat.mcp.access', + value: nls.localize('chat.mcp.access', "Controls access to installed Model Context Protocol servers.") + }, + enumDescriptions: [ + { + key: 'chat.mcp.access.none', value: nls.localize('chat.mcp.access.none', "No access to MCP servers."), + }, + { + key: 'chat.mcp.access.registry', value: nls.localize('chat.mcp.access.registry', "Allows access to MCP servers installed from the registry that VS Code is connected to."), + }, + { + key: 'chat.mcp.access.any', value: nls.localize('chat.mcp.access.any', "Allow access to any installed MCP server.") + } + ] + }, + } + }, + [mcpAutoStartConfig]: { + type: 'string', + description: nls.localize('chat.mcp.autostart', "Controls whether MCP servers should be automatically started when the chat messages are submitted."), + default: McpAutoStartValue.NewAndOutdated, + enum: [ + McpAutoStartValue.Never, + McpAutoStartValue.OnlyNew, + McpAutoStartValue.NewAndOutdated + ], + enumDescriptions: [ + nls.localize('chat.mcp.autostart.never', "Never automatically start MCP servers."), + nls.localize('chat.mcp.autostart.onlyNew', "Only automatically start new MCP servers that have never been run."), + nls.localize('chat.mcp.autostart.newAndOutdated', "Automatically start new and outdated MCP servers that are not yet running.") + ], + tags: ['experimental'], + }, + [mcpAppsEnabledConfig]: { + type: 'boolean', + description: nls.localize('chat.mcp.ui.enabled', "Controls whether MCP servers can provide custom UI for tool invocations."), + default: true, + tags: ['experimental'], + }, + [mcpServerCollisionBehaviorSection]: { + type: 'string', + description: nls.localize('chat.mcp.collisionBehavior', "Controls behavior when multiple MCP servers are discovered with the same name. 'disable' disables lower-priority duplicates. 'suffix' appends numeric suffixes to disambiguate."), + enum: [ + McpCollisionBehavior.Disable, + McpCollisionBehavior.Suffix, + ], + enumDescriptions: [ + nls.localize('chat.mcp.collisionBehavior.disable', "Disable lower-priority servers with duplicate names."), + nls.localize('chat.mcp.collisionBehavior.suffix', "Append numeric suffixes to servers with duplicate names."), + ], + default: McpCollisionBehavior.Disable, + }, + [mcpServerSamplingSection]: { + type: 'object', + description: nls.localize('chat.mcp.serverSampling', "Configures which models are exposed to MCP servers for sampling (making model requests in the background). This setting can be edited in a graphical way under the `{0}` command.", 'MCP: ' + nls.localize('mcp.list', 'List Servers')), + scope: ConfigurationScope.RESOURCE, + additionalProperties: { + type: 'object', + properties: { + allowedDuringChat: { + type: 'boolean', + description: nls.localize('chat.mcp.serverSampling.allowedDuringChat', "Whether this server is allowed to make sampling requests during its tool calls in a chat session."), + default: true, + }, + allowedOutsideChat: { + type: 'boolean', + description: nls.localize('chat.mcp.serverSampling.allowedOutsideChat', "Whether this server is allowed to make sampling requests outside of a chat session."), + default: false, + }, + allowedModels: { + type: 'array', + items: { + type: 'string', + description: nls.localize('chat.mcp.serverSampling.model', "A model the MCP server has access to."), + }, + } + } + }, + }, + [AssistedTypes[AddConfigurationType.NuGetPackage].enabledConfigKey]: { + type: 'boolean', + description: nls.localize('chat.mcp.assisted.nuget.enabled.description', "Enables NuGet packages for AI-assisted MCP server installation. Used to install MCP servers by name from the central registry for .NET packages (NuGet.org)."), + default: false, + tags: ['experimental'], + experiment: { + mode: 'startup' + } + }, + [ChatConfiguration.ExtensionToolsEnabled]: { + type: 'boolean', + description: nls.localize('chat.extensionToolsEnabled', "Enable using tools contributed by third-party extensions."), + default: true, + policy: { + name: 'ChatAgentExtensionTools', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.99', + localization: { + description: { + key: 'chat.extensionToolsEnabled', + value: nls.localize('chat.extensionToolsEnabled', "Enable using tools contributed by third-party extensions.") + } + }, + } + }, + [ChatConfiguration.PluginsEnabled]: { + type: 'boolean', + description: nls.localize('chat.plugins.enabled', "Enable agent plugin integration in chat."), + default: true, + tags: ['preview'], + policy: { + name: 'ChatPluginsEnabled', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.116', + localization: { + description: { + key: 'chat.plugins.enabled', + value: nls.localize('chat.plugins.enabled', "Enable agent plugin integration in chat."), + } + }, + }, + }, + [ChatConfiguration.PluginLocations]: { + type: 'object', + additionalProperties: { type: 'boolean' }, + restricted: true, + markdownDescription: nls.localize('chat.pluginLocations', "Plugin directories to discover. Each key is a path that points directly to a plugin folder, and the value enables (`true`) or disables (`false`) it. Paths can be absolute, relative to the workspace root, or start with `~/` for the user's home directory."), + scope: ConfigurationScope.MACHINE, + tags: ['experimental'], + }, + [ChatConfiguration.PluginMarketplaces]: { + type: 'array', + items: { + type: 'string', + }, + markdownDescription: nls.localize('chat.plugins.marketplaces', "Plugin marketplaces to query. Entries may be GitHub shorthand (`owner/repo`), direct Git repository URIs (`https://...git`, `ssh://...git`, or `git@host:path.git`), or local repository URIs (`file:///...`). Equivalent GitHub shorthand and URI entries are deduplicated."), + default: ['github/copilot-plugins', 'github/awesome-copilot'], + scope: ConfigurationScope.APPLICATION, + tags: ['experimental'], + }, + [ChatConfiguration.AgentEnabled]: { + type: 'boolean', + description: nls.localize('chat.agent.enabled.description', "When enabled, agent mode can be activated from chat and tools in agentic contexts with side effects can be used."), + default: true, + order: 1, + policy: { + name: 'ChatAgentMode', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.99', + value: (policyData) => policyData.chat_agent_enabled === false ? false : undefined, + localization: { + description: { + key: 'chat.agent.enabled.description', + value: nls.localize('chat.agent.enabled.description', "When enabled, agent mode can be activated from chat and tools in agentic contexts with side effects can be used."), + } + } + } + }, + [AgentNetworkDomainSettingId.NetworkFilter]: { + markdownDescription: nls.localize('chat.agent.networkFilter', "When enabled, network access by agent tools (fetch tool, integrated browser) is restricted according to {0} and {1}. Domain filtering is also applied to those tools when {2} is enabled.", `\`#${AgentNetworkDomainSettingId.AllowedNetworkDomains}#\``, `\`#${AgentNetworkDomainSettingId.DeniedNetworkDomains}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``), + type: 'boolean', + default: false, + restricted: true, + policy: { + name: 'ChatAgentNetworkFilter', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.116', + localization: { + description: { + key: 'chat.agent.networkFilter', + value: nls.localize('chat.agent.networkFilter', "When enabled, network access by agent tools (fetch tool, integrated browser) is restricted according to {0} and {1}. Domain filtering is also applied to those tools when {2} is enabled.", `\`#${AgentNetworkDomainSettingId.AllowedNetworkDomains}#\``, `\`#${AgentNetworkDomainSettingId.DeniedNetworkDomains}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``), + } + } + } + }, + [AgentNetworkDomainSettingId.AllowedNetworkDomains]: { + markdownDescription: nls.localize('chat.agent.allowedNetworkDomains', "Allowed domains for network access by agent tools (fetch tool, integrated browser). Applies when {0} or {1} is enabled. When {1} is set to {2}, all domains are allowed. Supports wildcards like {3}. When both allowed and denied lists are empty, all domains are blocked. Denied domains (see {4}) take precedence.", `\`#${AgentNetworkDomainSettingId.NetworkFilter}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``, `\`${AgentSandboxEnabledValue.AllowNetwork}\``, '`*.example.com`', `\`#${AgentNetworkDomainSettingId.DeniedNetworkDomains}#\``), + type: 'array', + items: { type: 'string' }, + default: [], + restricted: true, + policy: { + name: 'ChatAgentAllowedNetworkDomains', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.116', + localization: { + description: { + key: 'chat.agent.allowedNetworkDomains', + value: nls.localize('chat.agent.allowedNetworkDomains', "Allowed domains for network access by agent tools (fetch tool, integrated browser). Applies when {0} or {1} is enabled. When {1} is set to {2}, all domains are allowed. Supports wildcards like {3}. When both allowed and denied lists are empty, all domains are blocked. Denied domains (see {4}) take precedence.", `\`#${AgentNetworkDomainSettingId.NetworkFilter}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``, `\`${AgentSandboxEnabledValue.AllowNetwork}\``, '`*.example.com`', `\`#${AgentNetworkDomainSettingId.DeniedNetworkDomains}#\``), + } + } + } + }, + [AgentNetworkDomainSettingId.DeniedNetworkDomains]: { + markdownDescription: nls.localize('chat.agent.deniedNetworkDomains', "Denied domains for network access by agent tools (fetch tool, integrated browser). Applies when {0} or {1} is enabled. This does not apply when {1} is set to {2}. Takes precedence over {3}. Supports wildcards like {4}.", `\`#${AgentNetworkDomainSettingId.NetworkFilter}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``, `\`${AgentSandboxEnabledValue.AllowNetwork}\``, `\`#${AgentNetworkDomainSettingId.AllowedNetworkDomains}#\``, '`*.example.com`'), + type: 'array', + items: { type: 'string' }, + default: [], + restricted: true, + policy: { + name: 'ChatAgentDeniedNetworkDomains', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.116', + localization: { + description: { + key: 'chat.agent.deniedNetworkDomains', + value: nls.localize('chat.agent.deniedNetworkDomains', "Denied domains for network access by agent tools (fetch tool, integrated browser). Applies when {0} or {1} is enabled. This does not apply when {1} is set to {2}. Takes precedence over {3}. Supports wildcards like {4}.", `\`#${AgentNetworkDomainSettingId.NetworkFilter}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``, `\`${AgentSandboxEnabledValue.AllowNetwork}\``, `\`#${AgentNetworkDomainSettingId.AllowedNetworkDomains}#\``, '`*.example.com`'), + } + } + } + }, + [AgentNetworkDomainSettingId.DeprecatedOldAllowedNetworkDomains]: { + type: 'array', + items: { type: 'string' }, + deprecated: true, + markdownDeprecationMessage: nls.localize('agentSandbox.allowedNetworkDomains.deprecated', 'Use {0} instead', `\`#${AgentNetworkDomainSettingId.AllowedNetworkDomains}#\``), + }, + [AgentNetworkDomainSettingId.DeprecatedOldDeniedNetworkDomains]: { + type: 'array', + items: { type: 'string' }, + deprecated: true, + markdownDeprecationMessage: nls.localize('agentSandbox.deniedNetworkDomains.deprecated', 'Use {0} instead', `\`#${AgentNetworkDomainSettingId.DeniedNetworkDomains}#\``), + }, + [AgentNetworkDomainSettingId.DeprecatedSandboxAllowedNetworkDomains]: { + type: 'array', + items: { type: 'string' }, + deprecated: true, + markdownDeprecationMessage: nls.localize('agentSandbox.allowedNetworkDomains2.deprecated', 'Use {0} instead', `\`#${AgentNetworkDomainSettingId.AllowedNetworkDomains}#\``), + }, + [AgentNetworkDomainSettingId.DeprecatedSandboxDeniedNetworkDomains]: { + type: 'array', + items: { type: 'string' }, + deprecated: true, + markdownDeprecationMessage: nls.localize('agentSandbox.deniedNetworkDomains2.deprecated', 'Use {0} instead', `\`#${AgentNetworkDomainSettingId.DeniedNetworkDomains}#\``), + }, + [ChatConfiguration.DefaultNewSessionMode]: { + type: 'string', + description: nls.localize('chat.newSession.defaultMode', "The default mode for new chat sessions. When empty, the chat view's default mode is used."), + default: '', + }, + [AgentHostEnabledSettingId]: { + type: 'boolean', + description: nls.localize('chat.agentHost.enabled', "When enabled, some agents run in a separate agent host process."), + default: false, + tags: ['experimental', 'advanced'], + included: product.quality !== 'stable', + }, + [AgentHostClaudeAgentSdkPathSettingId]: { + type: 'string', + description: nls.localize('chat.agentHost.claudeAgent.path', "Experimental, for local testing only. Absolute path to a locally-installed `@anthropic-ai/claude-agent-sdk` package. When set, the Claude agent provider is registered inside the agent host and the SDK is loaded from this path. Requires `#chat.agentHost.enabled#`. The agent host process must be restarted for changes to take effect. This setting will be removed once the SDK is delivered through the Extension Marketplace."), + default: '', + tags: ['experimental', 'advanced'], + included: product.quality !== 'stable', + }, + [AgentHostIpcLoggingSettingId]: { + type: 'boolean', + description: nls.localize('chat.agentHost.ipcLogging', "When enabled, logs all IPC traffic for each agent host to a dedicated output channel."), + default: product.quality !== 'stable', + tags: ['experimental', 'advanced'], + included: product.quality !== 'stable', + }, + [AgentHostAhpJsonlLoggingSettingId]: { + type: 'boolean', + description: nls.localize('chat.agentHost.ahpJsonlLogging', "When enabled, logs all AHP transport messages for agent host connections to JSONL files under the window's log directory."), + default: product.quality !== 'stable', + tags: ['experimental', 'advanced'], + included: product.quality !== 'stable', + }, + [AgentHostCustomTerminalToolEnabledSettingId]: { + type: 'boolean', + description: nls.localize('chat.agentHost.customTerminalTool.enabled', "When enabled, Copilot SDK sessions use the Agent Host terminal tool override instead of the SDK's default terminal behavior."), + default: true, + tags: ['experimental', 'advanced'], + included: product.quality !== 'stable', + }, + [AgentHostOTelEnabledSettingId]: { + type: 'boolean', + description: nls.localize('chat.agentHost.otel.enabled', "When enabled, the agent host emits OpenTelemetry traces from the Copilot SDK. Requires `#chat.agentHost.enabled#`. Either configure `#chat.agentHost.otel.otlpEndpoint#` to ship traces to an external collector or enable `#chat.agentHost.otel.dbSpanExporter.enabled#` to capture them locally."), + default: false, + tags: ['experimental', 'advanced'], + included: product.quality !== 'stable', + }, + [AgentHostOTelExporterTypeSettingId]: { + type: 'string', + enum: ['otlp-http', 'otlp-grpc', 'console', 'file'], + description: nls.localize('chat.agentHost.otel.exporterType', "Exporter backend used by the Copilot SDK when `#chat.agentHost.otel.enabled#` is on. `otlp-grpc` is downgraded to `otlp-http` transparently in the CLI runtime."), + default: 'otlp-http', + tags: ['experimental', 'advanced'], + included: product.quality !== 'stable', + }, + [AgentHostOTelOtlpEndpointSettingId]: { + type: 'string', + description: nls.localize('chat.agentHost.otel.otlpEndpoint', "OTLP endpoint URL when exporter type is `otlp-http` or `otlp-grpc`. Sets `OTEL_EXPORTER_OTLP_ENDPOINT` inside the agent host process."), + default: '', + tags: ['experimental', 'advanced'], + included: product.quality !== 'stable', + }, + [AgentHostOTelCaptureContentSettingId]: { + type: 'boolean', + description: nls.localize('chat.agentHost.otel.captureContent', "When enabled, includes prompt and response content in OTel span attributes. Sets `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`. Privacy-sensitive: do not enable in environments that ship spans to shared sinks."), + default: false, + tags: ['experimental', 'advanced'], + included: product.quality !== 'stable', + }, + [AgentHostOTelOutfileSettingId]: { + type: 'string', + description: nls.localize('chat.agentHost.otel.outfile', "Output path for span JSON lines when exporter type is `file`. Sets `COPILOT_OTEL_FILE_EXPORTER_PATH`."), + default: '', + tags: ['experimental', 'advanced'], + included: product.quality !== 'stable', + }, + [AgentHostOTelDbSpanExporterEnabledSettingId]: { + type: 'boolean', + description: nls.localize('chat.agentHost.otel.dbSpanExporter.enabled', "When enabled, the agent host persists every emitted OTel span to a local SQLite database. Spans can be inspected via the `Export Agent Host Traces Database` command. Compatible with external exporters: spans are written to SQLite *and* forwarded to the user-configured sink."), + default: false, + tags: ['experimental', 'advanced'], + included: product.quality !== 'stable', + }, + [ChatConfiguration.AgentHostClientTools]: { + type: 'array', + items: { type: 'string' }, + description: nls.localize('chat.agentHost.clientTools', "Tool reference names to expose as client-provided tools in agent host sessions."), + default: [ + 'runTask', 'getTaskOutput', 'problems', 'runTests', + // These are always present in the {@link ChatConfiguration.AgentHostClientTools} default but + // out of these, only the tools that are actually registered/enabled are seen by the agent. + ...browserChatToolReferenceNames, + ], + tags: ['experimental', 'advanced'], + included: product.quality !== 'stable', + }, + [ChatConfiguration.ToolConfirmationCarousel]: { + type: 'boolean', + description: nls.localize('chat.tools.confirmationCarousel', "When enabled, multiple tool confirmations are batched into a carousel above the input."), + default: product.quality !== 'stable', + tags: ['experimental'], + }, + [ChatConfiguration.ToolRiskAssessmentEnabled]: { + type: 'boolean', + description: nls.localize('chat.tools.riskAssessment.enabled', "When enabled, terminal tool confirmations show an LLM-generated risk level (Safe / Caution / Review carefully) and a short explanation."), + default: true, + experiment: { + mode: 'auto' + }, + }, + [ChatConfiguration.ToolRiskAssessmentModel]: { + type: 'string', + description: nls.localize('chat.tools.riskAssessment.model', "The language model id used to generate tool risk assessments. Should be a small, fast model."), + default: 'copilot-utility-small', + tags: ['experimental', 'advanced'], + experiment: { + mode: 'auto' + }, + }, + [ChatConfiguration.PlanAgentDefaultModel]: { + type: 'string', + description: nls.localize('chat.planAgent.defaultModel.description', "Select the default language model to use for the Plan agent from the available providers."), + default: '', + enum: PlanAgentDefaultModel.modelIds, + enumItemLabels: PlanAgentDefaultModel.modelLabels, + markdownEnumDescriptions: PlanAgentDefaultModel.modelDescriptions + }, + [ChatConfiguration.ExploreAgentDefaultModel]: { + type: 'string', + description: nls.localize('chat.exploreAgent.defaultModel.description', "Select the default language model to use for the Explore subagent from the available providers."), + default: '', + enum: ExploreAgentDefaultModel.modelIds, + enumItemLabels: ExploreAgentDefaultModel.modelLabels, + markdownEnumDescriptions: ExploreAgentDefaultModel.modelDescriptions + }, + [ChatConfiguration.UtilityModel]: { + type: 'string', + description: nls.localize('chat.utilityModel.description', "Override the language model used by built-in utility flows (titles, summaries, fallback responses, etc.). Leave empty to use the default model."), + default: '', + enum: UtilityModelContribution.modelIds, + enumItemLabels: UtilityModelContribution.modelLabels, + markdownEnumDescriptions: UtilityModelContribution.modelDescriptions + }, + [ChatConfiguration.UtilitySmallModel]: { + type: 'string', + description: nls.localize('chat.utilitySmallModel.description', "Override the language model used by built-in small/fast utility flows (commit messages, intent detection, inline-chat progress, etc.). A fast and inexpensive model is recommended. Leave empty to use the default model."), + default: '', + enum: UtilitySmallModelContribution.modelIds, + enumItemLabels: UtilitySmallModelContribution.modelLabels, + markdownEnumDescriptions: UtilitySmallModelContribution.modelDescriptions + }, + [ChatConfiguration.RequestQueueingDefaultAction]: { + type: 'string', + enum: ['queue', 'steer'], + enumDescriptions: [ + nls.localize('chat.requestQueuing.defaultAction.queue', "Queue the message to send after the current request completes."), + nls.localize('chat.requestQueuing.defaultAction.steer', "Steer the current request by sending the message immediately, signaling the current request to yield."), + ], + description: nls.localize('chat.requestQueuing.defaultAction.description', "Controls which action is the default for the queue button when a request is in progress."), + default: 'steer', + }, + [ChatConfiguration.EditModeHidden]: { + type: 'boolean', + description: nls.localize('chat.editMode.hidden', "When enabled, hides the Edit mode from the chat mode picker."), + default: true, + tags: ['experimental'], + experiment: { + mode: 'auto' + }, + policy: { + name: 'DeprecatedEditModeHidden', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.112', + localization: { + description: { + key: 'chat.editMode.hidden', + value: nls.localize('chat.editMode.hidden', "When enabled, hides the Edit mode from the chat mode picker."), + } + } + } + }, + [ChatConfiguration.EnableMath]: { + type: 'boolean', + description: nls.localize('chat.mathEnabled.description', "Enable math rendering in chat responses using KaTeX."), + default: true, + }, + [ChatConfiguration.ShowCodeBlockProgressAnimation]: { + type: 'boolean', + description: nls.localize('chat.codeBlock.showProgressAnimation.description', "When applying edits, show a progress animation in the code block pill. If disabled, shows the progress percentage instead."), + default: true, + tags: ['experimental'], + }, + [mcpDiscoverySection]: { + type: 'object', + properties: Object.fromEntries(allDiscoverySources.map(k => [k, { type: 'boolean', description: discoverySourceSettingsLabel[k] }])), + additionalProperties: false, + default: Object.fromEntries(allDiscoverySources.map(k => [k, false])), + markdownDescription: nls.localize('mcp.discovery.enabled', "Configures discovery of Model Context Protocol servers from configuration from various other applications."), + }, + [mcpGalleryServiceEnablementConfig]: { + type: 'boolean', + default: false, + tags: ['preview'], + description: nls.localize('chat.mcp.gallery.enabled', "Enables the default Marketplace for Model Context Protocol (MCP) servers."), + included: product.quality === 'stable' + }, + [mcpGalleryServiceUrlConfig]: { + type: 'string', + description: nls.localize('mcp.gallery.serviceUrl', "Configure the MCP Gallery service URL to connect to"), + default: '', + scope: ConfigurationScope.APPLICATION, + tags: ['usesOnlineServices', 'advanced'], + included: false, + policy: { + name: 'McpGalleryServiceUrl', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.101', + value: (policyData) => policyData.mcpRegistryUrl, + localization: { + description: { + key: 'mcp.gallery.serviceUrl', + value: nls.localize('mcp.gallery.serviceUrl', "Configure the MCP Gallery service URL to connect to"), + } + } + }, + }, + [PromptsConfig.INSTRUCTIONS_LOCATION_KEY]: { + type: 'object', + title: nls.localize( + 'chat.instructions.config.locations.title', + "Instructions File Locations", + ), + markdownDescription: nls.localize( + 'chat.instructions.config.locations.description', + "Specify location(s) of instructions files (`*{0}`) that can be attached in Chat sessions. [Learn More]({1}).\n\nRelative paths are resolved from the root folder(s) of your workspace.", + INSTRUCTION_FILE_EXTENSION, + INSTRUCTIONS_DOCUMENTATION_URL, + ), + default: { + ...DEFAULT_INSTRUCTIONS_SOURCE_FOLDERS.map((folder) => ({ [folder.path]: true })).reduce((acc, curr) => ({ ...acc, ...curr }), {}), + }, + additionalProperties: { type: 'boolean' }, + propertyNames: { + pattern: VALID_PROMPT_FOLDER_PATTERN, + patternErrorMessage: nls.localize('chat.instructionsLocations.invalidPath', "Paths must be relative or start with '~/'. Absolute paths and '\\' separators are not supported. Glob patterns are deprecated and will be removed in future versions."), + }, + restricted: true, + tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'], + examples: [ + { + [DEFAULT_INSTRUCTIONS_SOURCE_FOLDERS[0].path]: true, + }, + { + [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true, + '/Users/vscode/repos/instructions': true, + }, + ], + }, + [PromptsConfig.PROMPT_LOCATIONS_KEY]: { + type: 'object', + title: nls.localize( + 'chat.reusablePrompts.config.locations.title', + "Prompt File Locations", + ), + markdownDescription: nls.localize( + 'chat.reusablePrompts.config.locations.description', + "Specify location(s) of reusable prompt files (`*{0}`) that can be run in Chat sessions. [Learn More]({1}).\n\nRelative paths are resolved from the root folder(s) of your workspace.", + PROMPT_FILE_EXTENSION, + PROMPT_DOCUMENTATION_URL, + ), + default: { + [PROMPT_DEFAULT_SOURCE_FOLDER]: true, + }, + additionalProperties: { type: 'boolean' }, + unevaluatedProperties: { type: 'boolean' }, + propertyNames: { + pattern: VALID_PROMPT_FOLDER_PATTERN, + patternErrorMessage: nls.localize('chat.promptFileLocations.invalidPath', "Paths must be relative or start with '~/'. Absolute paths and '\\' separators are not supported. Glob patterns are deprecated and will be removed in future versions."), + }, + restricted: true, + tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'], + examples: [ + { + [PROMPT_DEFAULT_SOURCE_FOLDER]: true, + }, + { + [PROMPT_DEFAULT_SOURCE_FOLDER]: true, + '/Users/vscode/repos/prompts': true, + }, + ], + }, + [PromptsConfig.MODE_LOCATION_KEY]: { + type: 'object', + title: nls.localize( + 'chat.mode.config.locations.title', + "Mode File Locations", + ), + markdownDescription: nls.localize( + 'chat.mode.config.locations.description', + "Specify location(s) of custom chat mode files (`*{0}`). [Learn More]({1}).\n\nRelative paths are resolved from the root folder(s) of your workspace.", + LEGACY_MODE_FILE_EXTENSION, + AGENT_DOCUMENTATION_URL, + ), + default: { + [LEGACY_MODE_DEFAULT_SOURCE_FOLDER]: true, + }, + deprecationMessage: nls.localize('chat.mode.config.locations.deprecated', "This setting is deprecated and will be removed in future releases. Chat modes are now called custom agents and are located in `.github/agents`"), + additionalProperties: { type: 'boolean' }, + unevaluatedProperties: { type: 'boolean' }, + restricted: true, + tags: ['experimental', 'prompts', 'reusable prompts', 'prompt snippets', 'instructions'], + examples: [ + { + [LEGACY_MODE_DEFAULT_SOURCE_FOLDER]: true, + }, + { + [LEGACY_MODE_DEFAULT_SOURCE_FOLDER]: true, + '/Users/vscode/repos/chatmodes': true, + }, + ], + }, + [PromptsConfig.AGENTS_LOCATION_KEY]: { + type: 'object', + title: nls.localize( + 'chat.agents.config.locations.title', + "Agent File Locations", + ), + markdownDescription: nls.localize( + 'chat.agents.config.locations.description', + "Specify location(s) of custom agent files (`*{0}`). [Learn More]({1}).\n\nRelative paths are resolved from the root folder(s) of your workspace.", + AGENT_FILE_EXTENSION, + AGENT_DOCUMENTATION_URL, + ), + default: { + [AGENTS_SOURCE_FOLDER]: true, + [CLAUDE_AGENTS_SOURCE_FOLDER]: true, + [COPILOT_USER_AGENTS_SOURCE_FOLDER]: true, + }, + additionalProperties: { type: 'boolean' }, + propertyNames: { + pattern: VALID_PROMPT_FOLDER_PATTERN, + patternErrorMessage: nls.localize('chat.agentLocations.invalidPath', "Paths must be relative or start with '~/'. Absolute paths and '\\' separators are not supported."), + }, + restricted: true, + tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'], + examples: [ + { + [AGENTS_SOURCE_FOLDER]: true, + }, + { + [AGENTS_SOURCE_FOLDER]: true, + 'my-agents': true, + '../shared-agents': true, + '~/.copilot/agents': true, + }, + ], + }, + [PromptsConfig.USE_AGENT_MD]: { + type: 'boolean', + title: nls.localize('chat.useAgentMd.title', "Use AGENTS.md file",), + markdownDescription: nls.localize('chat.useAgentMd.description', "Controls whether instructions from `AGENTS.md` file found in a workspace roots are attached to all chat requests.",), + default: true, + restricted: true, + disallowConfigurationDefault: true, + tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'] + }, + [PromptsConfig.USE_NESTED_AGENT_MD]: { + type: 'boolean', + title: nls.localize('chat.useNestedAgentMd.title', "Use nested AGENTS.md files",), + markdownDescription: nls.localize('chat.useNestedAgentMd.description', "Controls whether instructions from nested `AGENTS.md` files found in the workspace are listed in all chat requests. The language model can load these skills on-demand if the `read` tool is available.",), + default: false, + restricted: true, + disallowConfigurationDefault: true, + tags: ['experimental', 'prompts', 'reusable prompts', 'prompt snippets', 'instructions'] + }, + [PromptsConfig.USE_CLAUDE_MD]: { + type: 'boolean', + title: nls.localize('chat.useClaudeMd.title', "Use CLAUDE.md file",), + markdownDescription: nls.localize('chat.useClaudeMd.description', "Controls whether instructions from `CLAUDE.md` file found in workspace roots, .claude and ~/.claude folder are attached to all chat requests.",), + default: true, + restricted: true, + disallowConfigurationDefault: true, + tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'] + }, + [PromptsConfig.USE_AGENT_SKILLS]: { + type: 'boolean', + title: nls.localize('chat.useAgentSkills.title', "Use Agent skills",), + markdownDescription: nls.localize('chat.useAgentSkills.description', "Controls whether skills are provided as specialized capabilities to the chat requests. Skills are loaded from the folders configured in `#chat.agentSkillsLocations#`. The language model can load these skills on-demand if the `read` tool is available. Learn more about [Agent Skills](https://aka.ms/vscode-agent-skills).",), + default: true, + restricted: true, + disallowConfigurationDefault: true, + tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'] + }, + [PromptsConfig.USE_SKILL_ADHERENCE_PROMPT]: { + type: 'boolean', + title: nls.localize('chat.useSkillAdherencePrompt.title', "Use Skill Adherence Prompt",), + markdownDescription: nls.localize('chat.useSkillAdherencePrompt.description', "Controls whether a stronger skill adherence prompt is used that encourages the model to immediately invoke skills when relevant rather than just announcing them."), + default: false, + restricted: true, + disallowConfigurationDefault: true, + tags: ['experimental', 'prompts', 'reusable prompts', 'prompt snippets', 'instructions'], + experiment: { + mode: 'auto' + } + }, + [PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS]: { + type: 'boolean', + title: nls.localize('chat.includeApplyingInstructions.title', "Include Applying Instructions",), + markdownDescription: nls.localize('chat.includeApplyingInstructions.description', "Controls whether instructions with a matching 'applyTo' attribute are automatically included in chat requests.",), + default: true, + restricted: true, + disallowConfigurationDefault: true, + tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'] + }, + [PromptsConfig.INCLUDE_REFERENCED_INSTRUCTIONS]: { + type: 'boolean', + title: nls.localize('chat.includeReferencedInstructions.title', "Include Referenced Instructions",), + markdownDescription: nls.localize('chat.includeReferencedInstructions.description', "Controls whether referenced instructions are automatically included in chat requests.",), + default: false, + restricted: true, + disallowConfigurationDefault: true, + tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'] + }, + [PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS]: { + type: 'boolean', + title: nls.localize('chat.useCustomizationsInParentRepos.title', "Use Customizations in Parent Repositories",), + markdownDescription: nls.localize('chat.useCustomizationsInParentRepos.description', "Controls whether to use chat customization files in parent repositories.",), + default: false, + restricted: true, + disallowConfigurationDefault: true, + tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'] + }, + [PromptsConfig.SKILLS_LOCATION_KEY]: { + type: 'object', + title: nls.localize('chat.agentSkillsLocations.title', "Agent Skills Locations",), + markdownDescription: nls.localize( + 'chat.agentSkillsLocations.description', + "Specify location(s) of agent skills (`{0}`) that can be used in Chat Sessions. [Learn More]({1}).\n\nEach path should contain skill subfolders with SKILL.md files (e.g., add `my-skills` if you have `my-skills/skillA/SKILL.md`). Relative paths are resolved from the root folder(s) of your workspace.", + SKILL_FILENAME, + SKILL_DOCUMENTATION_URL, + ), + default: { + ...DEFAULT_SKILL_SOURCE_FOLDERS.map((folder) => ({ [folder.path]: true })).reduce((acc, curr) => ({ ...acc, ...curr }), {}), + }, + additionalProperties: { type: 'boolean' }, + propertyNames: { + pattern: VALID_PROMPT_FOLDER_PATTERN, + patternErrorMessage: nls.localize('chat.agentSkillsLocations.invalidPath', "Paths must be relative or start with '~/'. Absolute paths and '\\' separators are not supported."), + }, + restricted: true, + tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'], + examples: [ + { + [DEFAULT_SKILL_SOURCE_FOLDERS[0].path]: true, + }, + { + [DEFAULT_SKILL_SOURCE_FOLDERS[0].path]: true, + 'my-skills': true, + '../shared-skills': true, + '~/.custom/skills': true, + }, + ], + }, + [PromptsConfig.HOOKS_LOCATION_KEY]: { + type: 'object', + title: nls.localize('chat.hookFilesLocations.title', "Hook File Locations",), + markdownDescription: nls.localize( + 'chat.hookFilesLocations.description', + "Specify paths to hook configuration files that define custom shell commands to execute at strategic points in an agent's workflow. [Learn More]({0}).\n\nRelative paths are resolved from the root folder(s) of your workspace. Supports Copilot hooks (`*.json`) and Claude Code hooks (`settings.json`, `settings.local.json`).", + HOOK_DOCUMENTATION_URL, + ), + default: { + ...DEFAULT_HOOK_FILE_PATHS.map((f) => ({ [f.path]: true })).reduce((acc, curr) => ({ ...acc, ...curr }), {}), + }, + additionalProperties: { type: 'boolean' }, + propertyNames: { + pattern: VALID_PROMPT_FOLDER_PATTERN, + patternErrorMessage: nls.localize('chat.hookFilesLocations.invalidPath', "Paths must be relative or start with '~/'. Absolute paths and '\\' separators are not supported."), + }, + restricted: true, + tags: ['preview', 'prompts', 'hooks', 'agent'], + examples: [ + { + [DEFAULT_HOOK_FILE_PATHS[0].path]: true, + }, + { + [DEFAULT_HOOK_FILE_PATHS[0].path]: true, + 'custom-hooks/hooks.json': true, + }, + ], + agentsWindow: { default: { '.claude/settings.local.json': false, '.claude/settings.json': false, '~/.claude/settings.json': false } }, + }, + [PromptsConfig.USE_CHAT_HOOKS]: { + type: 'boolean', + title: nls.localize('chat.useHooks.title', "Use Chat Hooks",), + markdownDescription: nls.localize('chat.useHooks.description', "Controls whether chat hooks are executed at strategic points during an agent's workflow. Hooks are loaded from the files configured in `#chat.hookFilesLocations#`.",), + default: true, + restricted: true, + disallowConfigurationDefault: true, + tags: ['preview', 'prompts', 'hooks', 'agent'], + policy: { + name: 'ChatHooks', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.109', + value: (policyData) => policyData.chat_preview_features_enabled === false ? false : undefined, + localization: { + description: { + key: 'chat.useHooks.description', + value: nls.localize('chat.useHooks.description', "Controls whether chat hooks are executed at strategic points during an agent's workflow. Hooks are loaded from the files configured in `#chat.hookFilesLocations#`.",) + } + }, + } + }, + [PromptsConfig.USE_CLAUDE_HOOKS]: { + type: 'boolean', + title: nls.localize('chat.useClaudeHooks.title', "Use Claude Hooks",), + markdownDescription: nls.localize('chat.useClaudeHooks.description', "Controls whether hooks from Claude configuration files can execute. When disabled, only Copilot-format hooks are used. Hooks are loaded from the files configured in `#chat.hookFilesLocations#`.",), + default: false, + restricted: true, + disallowConfigurationDefault: true, + tags: ['preview', 'prompts', 'hooks', 'agent'] + }, + [PromptsConfig.PROMPT_FILES_SUGGEST_KEY]: { + type: 'object', + scope: ConfigurationScope.RESOURCE, + title: nls.localize( + 'chat.promptFilesRecommendations.title', + "Prompt File Recommendations", + ), + markdownDescription: nls.localize( + 'chat.promptFilesRecommendations.description', + "Configure which prompt files to recommend in the chat welcome view. Each key is a prompt file name, and the value can be `true` to always recommend, `false` to never recommend, or a [when clause](https://aka.ms/vscode-when-clause) expression like `resourceExtname == .js` or `resourceLangId == markdown`.", + ), + default: {}, + additionalProperties: { + oneOf: [ + { type: 'boolean' }, + { type: 'string' } + ] + }, + tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'], + examples: [ + { + 'plan': true, + 'a11y-audit': 'resourceExtname == .html', + 'document': 'resourceLangId == markdown' + } + ], + }, + [ChatConfiguration.TodosShowWidget]: { + type: 'boolean', + default: true, + description: nls.localize('chat.tools.todos.showWidget', "Controls whether to show the todo list widget above the chat input. When enabled, the widget displays todo items created by the agent and updates as progress is made."), + }, + [ChatConfiguration.ThinkingStyle]: { + type: 'string', + default: 'fixedScrolling', + enum: ['collapsed', 'collapsedPreview', 'fixedScrolling'], + enumDescriptions: [ + nls.localize('chat.agent.thinkingMode.collapsed', "Thinking parts will be collapsed by default."), + nls.localize('chat.agent.thinkingMode.collapsedPreview', "Thinking parts will be expanded first, then collapse once we reach a part that is not thinking."), + nls.localize('chat.agent.thinkingMode.fixedScrolling', "Show thinking in a fixed-height streaming panel that auto-scrolls; click header to expand to full height."), + ], + description: nls.localize('chat.agent.thinkingStyle', "Controls how thinking is rendered."), + tags: ['experimental'], + }, + [ChatConfiguration.ThinkingGenerateTitles]: { + type: 'boolean', + default: true, + description: nls.localize('chat.agent.thinking.generateTitles', "Controls whether to use an LLM to generate summary titles for thinking sections."), + tags: ['experimental'], + }, + 'chat.agent.thinking.collapsedTools': { + type: 'string', + default: 'always', + enum: ['off', 'withThinking', 'always'], + enumDescriptions: [ + nls.localize('chat.agent.thinking.collapsedTools.off', "Tool calls are shown separately, not collapsed into thinking."), + nls.localize('chat.agent.thinking.collapsedTools.withThinking', "Tool calls are collapsed into thinking sections when thinking is present."), + nls.localize('chat.agent.thinking.collapsedTools.always', "Tool calls are always collapsed, even without thinking."), + ], + markdownDescription: nls.localize('chat.agent.thinking.collapsedTools', "Controls how tool calls are displayed in relation to thinking sections."), + tags: ['experimental'], + }, + [ChatConfiguration.TerminalToolsInThinking]: { + type: 'boolean', + default: true, + markdownDescription: nls.localize('chat.agent.thinking.terminalTools', "When enabled, terminal tool calls are displayed inside the thinking dropdown with a simplified view."), + tags: ['experimental'], + }, + [ChatConfiguration.SimpleTerminalCollapsible]: { + type: 'boolean', + default: true, + markdownDescription: nls.localize('chat.tools.terminal.simpleCollapsible', "When enabled, terminal tool calls are always displayed in a collapsible container with a simplified view."), + tags: ['experimental'], + }, + [ChatConfiguration.CompressOutputEnabled]: { + type: 'boolean', + default: false, + markdownDescription: nls.localize('chat.tools.compressOutput.enabled', "Post-process tool output (for example `git diff`, `ls -l`, or `npm install`) to reduce token usage before it is sent to the model."), + tags: ['preview'], + experiment: { + mode: 'auto' + } + }, + [ChatConfiguration.ThinkingPhrases]: { + type: 'object', + default: { + mode: 'append', + phrases: [] + }, + properties: { + mode: { + type: 'string', + enum: ['replace', 'append'], + default: 'append', + description: nls.localize('chat.agent.thinking.phrases.mode', "'replace' replaces all default phrases entirely; 'append' adds your phrases to all default categories.") + }, + phrases: { + type: 'array', + items: { type: 'string' }, + default: [], + description: nls.localize('chat.agent.thinking.phrases.phrases', "Custom loading messages to show during thinking, working progress, terminal, and tool operations.") + } + }, + additionalProperties: false, + markdownDescription: nls.localize('chat.agent.thinking.phrases', "Customize the loading messages shown during agent thinking and progress indicators. Use `\"mode\": \"replace\"` to use only your phrases, or `\"mode\": \"append\"` to add them to the defaults."), + tags: ['experimental'], + }, + [ChatConfiguration.AutoExpandToolFailures]: { + type: 'boolean', + default: true, + markdownDescription: nls.localize('chat.tools.autoExpandFailures', "When enabled, tool failures are automatically expanded in the chat UI to show error details."), + }, + [ChatConfiguration.AIDisabled]: { + type: 'boolean', + description: nls.localize('chat.disableAIFeatures', "Disable and hide built-in AI features provided by GitHub Copilot, including chat and inline suggestions."), + default: false, + scope: ConfigurationScope.WINDOW, + }, + [ChatConfiguration.OfflineByok]: { + type: 'boolean', + description: nls.localize('chat.offlineByok', "Experimental: enable BYOK chat features without GitHub sign-in."), + default: false, + scope: ConfigurationScope.WINDOW, + included: false, + }, + [ChatConfiguration.TitleBarSignInEnabled]: { + type: 'boolean', + description: nls.localize('chat.titleBar.signIn.enabled', "Controls whether the Copilot Sign In button is shown in the title bar when signed out. When disabled, the Sign In affordance falls back to the status bar."), + default: true, + }, + 'chat.approvedAccountOrganizations': { + type: 'array', + items: { type: 'string' }, + description: nls.localize('chat.approvedAccountOrganizations', "List of GitHub organization logins whose members are permitted to use AI features. When set to a non-empty list, AI features are disabled until the user signs into a GitHub account that belongs to one of the specified organizations and account-level policy data has been resolved. Set to '*' to allow any authenticated GitHub or GitHub Enterprise account."), + default: [], + included: false, + policy: { + name: 'ChatApprovedAccountOrganizations', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.118', + localization: { + description: { + key: 'chat.approvedAccountOrganizations.policy.description', + value: nls.localize('chat.approvedAccountOrganizations.policy.description', "Setting this policy to a non-empty list activates the Approved Account gate: all AI features are disabled until the user signs into a GitHub account whose organizations intersect this list AND the account-side policy data has resolved. Comparison is case-insensitive. Use '*' as a wildcard to accept any signed-in GitHub or GHE account (use this for GHE deployments where the organization list is not surfaced).") + } + } + } + }, + 'chat.allowAnonymousAccess': { // TODO@bpasero remove me eventually + type: 'boolean', + description: nls.localize('chat.allowAnonymousAccess', "Controls whether anonymous access is allowed in chat."), + default: false, + tags: ['experimental'], + experiment: { + mode: 'auto' + } + }, + [ChatConfiguration.GrowthNotificationEnabled]: { + type: 'boolean', + description: nls.localize('chat.growthNotification', "Controls whether to show a growth notification in the agent sessions view to encourage new users to try Copilot."), + default: false, + tags: ['experimental'], + experiment: { + mode: 'auto' + } + }, + [ChatConfiguration.RestoreLastPanelSession]: { + type: 'boolean', + description: nls.localize('chat.restoreLastPanelSession', "Controls whether the last session is restored in panel after restart."), + default: false + }, + [ChatConfiguration.ExitAfterDelegation]: { + type: 'boolean', + description: nls.localize('chat.exitAfterDelegation', "Controls whether the chat panel automatically exits after delegating a request to another session."), + default: false, + tags: ['preview'], + }, + 'chat.extensionUnification.enabled': { + type: 'boolean', + description: nls.localize('chat.extensionUnification.enabled', "Enables the unification of GitHub Copilot extensions. When enabled, all GitHub Copilot functionality is served from the GitHub Copilot Chat extension. When disabled, the GitHub Copilot and GitHub Copilot Chat extensions operate independently."), + default: true, + tags: ['experimental'], + experiment: { + mode: 'auto' + } + }, + [ChatConfiguration.GeneralPurposeAgentEnabled]: { + type: 'boolean', + description: nls.localize('chat.generalPurposeAgent.enabled', "Controls whether the built-in General Purpose agent is available as a subagent."), + default: false, + tags: ['experimental', 'advanced'], + experiment: { + mode: 'auto' + } + }, + [ChatConfiguration.SubagentsAllowInvocationsFromSubagents]: { + type: 'boolean', + description: nls.localize('chat.subagents.allowInvocationsFromSubagents', "Allow subagents to invoke subagents."), + markdownDescription: nls.localize('chat.subagents.allowInvocationsFromSubagents.md', "Controls whether subagents can invoke other subagents. When enabled, nesting is limited to a maximum depth of 5."), + default: false, + experiment: { + mode: 'auto' + } + }, + + [ChatConfiguration.ChatCustomizationHarnessSelectorEnabled]: { + type: 'boolean', + tags: ['preview'], + description: nls.localize('chat.customizations.harnessSelector.enabled', "Controls whether the harness selector is shown in the Chat Customizations editor sidebar. When disabled, the editor always shows all customizations without filtering."), + default: true, + }, + [ChatConfiguration.ChatCustomizationsStructuredPreviewEnabled]: { + type: 'boolean', + tags: ['preview'], + description: nls.localize('chat.customizations.structuredPreview.enabled', "Controls whether the Chat Customizations editor shows a structured preview for markdown customization files (agents, skills, instructions, prompts). When disabled, the editor always opens the raw markdown in the embedded code editor."), + default: false, + }, + [ChatConfiguration.UseChatSessionCustomizationsForCustomAgents]: { + type: 'boolean', + description: nls.localize('chat.customizations.useChatSessionCustomizationsForCustomAgents', "When enabled, custom agents shown in the chat mode picker are sourced from the customization harness service (scoped per session type) instead of the prompts service."), + default: false, + tags: ['experimental', 'advanced'], + experiment: { + mode: 'auto' + } + }, + } +}); +Registry.as(EditorExtensions.EditorPane).registerEditorPane( + EditorPaneDescriptor.create( + ChatEditor, + ChatEditorInput.EditorID, + nls.localize('chat', "Chat") + ), + [ + new SyncDescriptor(ChatEditorInput) + ] +); +Registry.as(EditorExtensions.EditorPane).registerEditorPane( + EditorPaneDescriptor.create( + ChatDebugEditor, + ChatDebugEditorInput.ID, + nls.localize('chatDebug', "Debug View") + ), + [ + new SyncDescriptor(ChatDebugEditorInput) + ] +); +Registry.as(EditorExtensions.EditorPane).registerEditorPane( + EditorPaneDescriptor.create( + AgentPluginEditor, + AgentPluginEditor.ID, + nls.localize('agentPlugin', "Agent Plugin") + ), + [ + new SyncDescriptor(AgentPluginEditorInput) + ] +); +Registry.as(Extensions.ConfigurationMigration).registerConfigurationMigrations([ + { + key: 'chat.experimental.detectParticipant.enabled', + migrateFn: (value, _accessor) => ([ + ['chat.experimental.detectParticipant.enabled', { value: undefined }], + ['chat.detectParticipant.enabled', { value: value !== false }] + ]) + }, + { + key: 'chat.useClaudeSkills', + migrateFn: (value, _accessor) => ([ + ['chat.useClaudeSkills', { value: undefined }], + ['chat.useAgentSkills', { value }] + ]) + }, + { + key: mcpDiscoverySection, + migrateFn: (value: unknown) => { + if (typeof value === 'boolean') { + return { value: Object.fromEntries(allDiscoverySources.map(k => [k, value])) }; + } + + return { value }; + } + }, + { + key: ChatConfiguration.NotifyWindowOnConfirmation, + migrateFn: (value: unknown) => { + if (value === true) { + return { value: ChatNotificationMode.WindowNotFocused }; + } else if (value === false) { + return { value: ChatNotificationMode.Off }; + } + return []; + } + }, + { + key: ChatConfiguration.NotifyWindowOnResponseReceived, + migrateFn: (value: unknown) => { + if (value === true) { + return { value: ChatNotificationMode.WindowNotFocused }; + } else if (value === false) { + return { value: ChatNotificationMode.Off }; + } + return []; + } + }, + { + key: 'chat.plugins.paths', + migrateFn: (value: unknown, _accessor) => ([ + ['chat.plugins.paths', { value: undefined }], + [ChatConfiguration.PluginLocations, { value }] + ]) + }, + { + key: AgentNetworkDomainSettingId.DeprecatedSandboxAllowedNetworkDomains, + migrateFn: (value, accessor) => { + const pairs: ConfigurationKeyValuePairs = []; + pairs.push([AgentNetworkDomainSettingId.DeprecatedSandboxAllowedNetworkDomains, { value: undefined }]); + if (value !== undefined && accessor(AgentNetworkDomainSettingId.AllowedNetworkDomains) === undefined) { + pairs.push([AgentNetworkDomainSettingId.AllowedNetworkDomains, { value }]); + } + return pairs; + } + }, + { + key: AgentNetworkDomainSettingId.DeprecatedSandboxDeniedNetworkDomains, + migrateFn: (value, accessor) => { + const pairs: ConfigurationKeyValuePairs = []; + pairs.push([AgentNetworkDomainSettingId.DeprecatedSandboxDeniedNetworkDomains, { value: undefined }]); + if (value !== undefined && accessor(AgentNetworkDomainSettingId.DeniedNetworkDomains) === undefined) { + pairs.push([AgentNetworkDomainSettingId.DeniedNetworkDomains, { value }]); + } + return pairs; + } + }, + { + key: AgentNetworkDomainSettingId.DeprecatedOldAllowedNetworkDomains, + migrateFn: (value, accessor) => { + const pairs: ConfigurationKeyValuePairs = []; + pairs.push([AgentNetworkDomainSettingId.DeprecatedOldAllowedNetworkDomains, { value: undefined }]); + if (value !== undefined && accessor(AgentNetworkDomainSettingId.AllowedNetworkDomains) === undefined) { + pairs.push([AgentNetworkDomainSettingId.AllowedNetworkDomains, { value }]); + } + return pairs; + } + }, + { + key: AgentNetworkDomainSettingId.DeprecatedOldDeniedNetworkDomains, + migrateFn: (value, accessor) => { + const pairs: ConfigurationKeyValuePairs = []; + pairs.push([AgentNetworkDomainSettingId.DeprecatedOldDeniedNetworkDomains, { value: undefined }]); + if (value !== undefined && accessor(AgentNetworkDomainSettingId.DeniedNetworkDomains) === undefined) { + pairs.push([AgentNetworkDomainSettingId.DeniedNetworkDomains, { value }]); + } + return pairs; + } + }, +]); + +class ChatResolverContribution extends Disposable { + + static readonly ID = 'workbench.contrib.chatResolver'; + + private readonly _editorRegistrations = this._register(new DisposableMap()); + + constructor( + @IChatSessionsService chatSessionsService: IChatSessionsService, + @IEditorResolverService private readonly editorResolverService: IEditorResolverService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + + this._registerEditor(Schemas.vscodeChatEditor); + this._registerEditor(Schemas.vscodeLocalChatSession); + + this._register(chatSessionsService.onDidChangeContentProviderSchemes((e) => { + for (const scheme of e.added) { + this._registerEditor(scheme); + } + for (const scheme of e.removed) { + this._editorRegistrations.deleteAndDispose(scheme); + } + })); + + for (const scheme of chatSessionsService.getContentProviderSchemes()) { + this._registerEditor(scheme); + } + } + + private _registerEditor(scheme: string): void { + this._editorRegistrations.set(scheme, this.editorResolverService.registerEditor(`${scheme}:**/**`, + { + id: ChatEditorInput.EditorID, + label: nls.localize('chat', "Chat"), + priority: RegisteredEditorPriority.builtin + }, + { + singlePerResource: true, + canSupportResource: resource => resource.scheme === scheme, + }, + { + createEditorInput: ({ resource, options }) => { + return { + editor: this.instantiationService.createInstance(ChatEditorInput, resource, options as IChatEditorOptions), + options + }; + } + } + )); + } +} + +class CopilotTelemetryContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.copilotTelemetry'; + + constructor( + @ITelemetryService private readonly telemetryService: ITelemetryService, + @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, + ) { + super(); + + this.updateCopilotTrackingId(); + + this._register(this.chatEntitlementService.onDidChangeEntitlement(() => { + this.updateCopilotTrackingId(); + })); + } + + private updateCopilotTrackingId(): void { + const copilotTrackingId = this.chatEntitlementService.copilotTrackingId; + if (copilotTrackingId) { + // __GDPR__COMMON__ "common.copilotTrackingId" : { "endPoint": "GoogleAnalyticsID", "classification": "EndUserPseudonymizedInformation", "purpose": "BusinessInsight", "comment": "The anonymized Copilot analytics tracking ID from the entitlement API." } + this.telemetryService.setCommonProperty('common.copilotTrackingId', copilotTrackingId); + } + } +} + +class ChatDebugResolverContribution implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.chatDebugResolver'; + + constructor( + @IEditorResolverService editorResolverService: IEditorResolverService, + ) { + editorResolverService.registerEditor( + `${ChatDebugEditorInput.RESOURCE.scheme}:**/**`, + { + id: ChatDebugEditorInput.ID, + label: nls.localize('chatDebug', "Debug View"), + priority: RegisteredEditorPriority.exclusive + }, + { + singlePerResource: true, + canSupportResource: resource => resource.scheme === ChatDebugEditorInput.RESOURCE.scheme + }, + { + createEditorInput: () => { + return { + editor: ChatDebugEditorInput.instance, + options: { pinned: true } + }; + } + } + ); + } +} + +class ChatAgentSettingContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.chatAgentSetting'; + private readonly newChatButtonExperimentIcon; + + constructor( + @IWorkbenchAssignmentService private readonly experimentService: IWorkbenchAssignmentService, + @IChatEntitlementService private readonly entitlementService: IChatEntitlementService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + ) { + super(); + this.newChatButtonExperimentIcon = ChatContextKeys.newChatButtonExperimentIcon.bindTo(this.contextKeyService); + this.registerMaxRequestsSetting(); + this.registerNewChatButtonIcon(); + this.registerDefaultModeSetting(); + } + + + private registerMaxRequestsSetting(): void { + let lastNode: IConfigurationNode | undefined; + const registerMaxRequestsSetting = () => { + const treatmentId = this.entitlementService.entitlement === ChatEntitlement.Free ? + 'chatAgentMaxRequestsFree' : + 'chatAgentMaxRequestsPro'; + this.experimentService.getTreatment(treatmentId).then((value) => { + const node: IConfigurationNode = { + id: 'chatSidebar', + title: nls.localize('interactiveSessionConfigurationTitle', "Chat"), + type: 'object', + properties: { + 'chat.agent.maxRequests': { + type: 'number', + markdownDescription: nls.localize('chat.agent.maxRequests', "The maximum number of requests to allow per-turn when using an agent. When the limit is reached, will ask to confirm to continue."), + default: value ?? 50, + order: 2, + agentsWindow: { default: 1000 }, + }, + } + }; + configurationRegistry.updateConfigurations({ remove: lastNode ? [lastNode] : [], add: [node] }); + lastNode = node; + }); + }; + this._register(Event.runAndSubscribe(Event.debounce(this.entitlementService.onDidChangeEntitlement, () => { }, 1000), () => registerMaxRequestsSetting())); + } + + private registerNewChatButtonIcon(): void { + this.experimentService.getTreatment('chatNewButtonIcon').then((value) => { + const supportedValues = ['copilot', 'new-session', 'comment']; + if (typeof value === 'string' && supportedValues.includes(value)) { + this.newChatButtonExperimentIcon.set(value); + } else { + this.newChatButtonExperimentIcon.reset(); + } + }); + } + + private registerDefaultModeSetting(): void { + this.experimentService.getTreatment('chatDefaultNewSessionMode').then(value => { + const node: IConfigurationNode = { + id: 'chatSidebar', + title: nls.localize('interactiveSessionConfigurationTitle', "Chat"), + type: 'object', + properties: { + [ChatConfiguration.DefaultNewSessionMode]: { + type: 'string', + description: nls.localize('chat.newSession.defaultMode', "The default mode for new chat sessions. When empty, the chat view's default mode is used."), + default: typeof value === 'string' ? value : '', + } + } + }; + configurationRegistry.updateConfigurations({ add: [node], remove: [] }); + }); + } +} + +class ChatForegroundSessionCountContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.chatForegroundSessionCount'; + + private readonly foregroundSessionCountContextKey: IContextKey; + + constructor( + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IViewsService private readonly viewsService: IViewsService, + @IEditorService private readonly editorService: IEditorService, + ) { + super(); + this.foregroundSessionCountContextKey = ChatContextKeys.foregroundSessionCount.bindTo(this.contextKeyService); + + this._register(this.chatWidgetService.onDidAddWidget(() => { + this.updateForegroundSessionCount(); + })); + + this._register(this.editorService.onDidVisibleEditorsChange(() => { + this.updateForegroundSessionCount(); + })); + + this._register(Event.filter(this.viewsService.onDidChangeViewVisibility, e => e.id === ChatViewId)(() => { + this.updateForegroundSessionCount(); + })); + + this.updateForegroundSessionCount(); + } + + private updateForegroundSessionCount(): void { + let count = this.viewsService.isViewVisible(ChatViewId) ? 1 : 0; + + for (const widget of this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Chat)) { + if (widget.domNode.offsetParent === null) { + continue; + } + + if (isIChatViewViewContext(widget.viewContext)) { + continue; + } + + if (isIChatResourceViewContext(widget.viewContext) && widget.viewContext.isQuickChat) { + continue; + } + + count++; + } + + this.foregroundSessionCountContextKey.set(count); + } +} + + +/** + * Given builtin and custom modes, returns only the custom mode IDs that should have actions registered. + * Custom modes whose names conflict with builtin modes are excluded. + * If there are name collisions among custom modes, the later mode in the list wins. + */ +function getCustomModesWithUniqueNames(builtinModes: readonly IChatMode[], customModes: readonly IChatMode[]): Set { + const customModeIds = new Set(); + const builtinNames = new Set(builtinModes.map(mode => mode.name.get())); + const customNameToId = new Map(); + + for (const mode of customModes) { + const modeName = mode.name.get(); + + // Skip custom modes that conflict with builtin mode names + if (builtinNames.has(modeName)) { + continue; + } + + // If there is a name collision among custom modes, the later one in the list wins + const existingId = customNameToId.get(modeName); + if (existingId) { + customModeIds.delete(existingId); + } + + customNameToId.set(modeName, mode.id); + customModeIds.add(mode.id); + } + + return customModeIds; +} + +/** + * Workbench contribution to register actions for custom chat modes via events + */ +class ChatAgentActionsContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.chatAgentActions'; + + private readonly _modeActionDisposables = new DisposableMap(); + + constructor( + @IChatModeService _chatModeService: IChatModeService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + ) { + super(); + this._store.add(this._modeActionDisposables); + + const focusedWidget = observableFromEvent(this, this.chatWidgetService.onDidChangeFocusedSession, () => this.chatWidgetService.lastFocusedWidget); + this._register(autorun(reader => { + const chatModes = focusedWidget.read(reader)?.input.currentChatModesObs.read(reader); + this._syncModeActions(chatModes); + })); + } + + private _syncModeActions(chatModes: IChatModes | undefined): void { + if (!chatModes) { + this._modeActionDisposables.clearAndDisposeAll(); + return; + } + + const { builtin, custom } = chatModes; + const currentModeIds = getCustomModesWithUniqueNames(builtin, custom); + + // Remove modes that no longer exist and those replaced by modes later in the list with same name. + for (const modeId of this._modeActionDisposables.keys()) { + if (!currentModeIds.has(modeId)) { + this._modeActionDisposables.deleteAndDispose(modeId); + } + } + + // Register new modes. + for (const mode of custom) { + if (currentModeIds.has(mode.id) && !this._modeActionDisposables.has(mode.id)) { + this._registerModeAction(mode); + } + } + } + + private _registerModeAction(mode: IChatMode): void { + const actionClass = class extends ModeOpenChatGlobalAction { + constructor() { + super(mode); + } + }; + this._modeActionDisposables.set(mode.id, registerAction2(actionClass)); + } +} + +class HookSchemaAssociationContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.hookSchemaAssociation'; + + private readonly _registrations = this._register(new DisposableStore()); + + constructor( + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IPathService private readonly _pathService: IPathService, + ) { + super(); + this._updateAssociations(); + this._register(this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(PromptsConfig.HOOKS_LOCATION_KEY)) { + this._updateAssociations(); + } + })); + } + + private async _updateAssociations(): Promise { + this._registrations.clear(); + + const folders = PromptsConfig.promptSourceFolders(this._configurationService, PromptsType.hook); + const userHomeUri = await this._pathService.userHome(); + const userHome = userHomeUri.fsPath ?? userHomeUri.path; + + for (const folder of folders) { + // Skip Claude settings files — they use a different schema format + if (folder.source === PromptFileSource.ClaudeWorkspace || folder.source === PromptFileSource.ClaudeWorkspaceLocal || folder.source === PromptFileSource.ClaudePersonal) { + continue; + } + + // Expand tilde paths to absolute paths so the JSON language service can match them + const resolvedPath = isTildePath(folder.path) + ? userHome + folder.path.substring(1) + : folder.path; + + // If it's a specific .json file, use it directly; otherwise treat as directory + const glob = resolvedPath.toLowerCase().endsWith('.json') + ? resolvedPath + : `${resolvedPath}/*.json`; + + this._registrations.add( + jsonContributionRegistry.registerSchemaAssociation(HOOK_SCHEMA_URI, glob) + ); + } + } +} + +class ToolReferenceNamesContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.toolReferenceNames'; + + constructor( + @ILanguageModelToolsService private readonly _languageModelToolsService: ILanguageModelToolsService, + ) { + super(); + this._updateToolReferenceNames(); + this._register(this._languageModelToolsService.onDidChangeTools(() => this._updateToolReferenceNames())); + } + + private _updateToolReferenceNames(): void { + const tools = + Array.from(this._languageModelToolsService.getAllToolsIncludingDisabled()) + .filter((tool): tool is typeof tool & { toolReferenceName: string } => typeof tool.toolReferenceName === 'string') + .sort((a, b) => a.toolReferenceName.localeCompare(b.toolReferenceName)); + toolReferenceNameEnumValues.length = 0; + toolReferenceNameEnumDescriptions.length = 0; + for (const tool of tools) { + toolReferenceNameEnumValues.push(tool.toolReferenceName); + toolReferenceNameEnumDescriptions.push(nls.localize( + 'chat.toolReferenceName.description', + "{0} - {1}", + tool.toolReferenceName, + tool.userDescription || tool.displayName + )); + } + configurationRegistry.notifyConfigurationSchemaUpdated({ + id: 'chatSidebar', + properties: { + [ChatConfiguration.EligibleForAutoApproval]: {} + } + }); + } +} + +AccessibleViewRegistry.register(new ChatTerminalOutputAccessibleView()); +AccessibleViewRegistry.register(new ChatResponseAccessibleView()); +AccessibleViewRegistry.register(new PanelChatAccessibilityHelp()); +AccessibleViewRegistry.register(new QuickChatAccessibilityHelp()); +AccessibleViewRegistry.register(new EditsChatAccessibilityHelp()); +AccessibleViewRegistry.register(new AgentChatAccessibilityHelp()); + +registerEditorFeature(ChatInputBoxContentProvider); +Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer(ChatEditorInput.TypeID, ChatEditorInputSerializer); +Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer(ChatDebugEditorInput.ID, ChatDebugEditorInputSerializer); + +registerWorkbenchContribution2(CopilotTelemetryContribution.ID, CopilotTelemetryContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChatResolverContribution.ID, ChatResolverContribution, WorkbenchPhase.BlockStartup); +registerWorkbenchContribution2(ChatDebugResolverContribution.ID, ChatDebugResolverContribution, WorkbenchPhase.BlockStartup); +registerWorkbenchContribution2(PromptsDebugContribution.ID, PromptsDebugContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChatLanguageModelsDataContribution.ID, ChatLanguageModelsDataContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChatSlashCommandsContribution.ID, ChatSlashCommandsContribution, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(ChatSessionOptionSlashCommandsContribution.ID, ChatSessionOptionSlashCommandsContribution, WorkbenchPhase.Eventually); + +registerWorkbenchContribution2(ChatExtensionPointHandler.ID, ChatExtensionPointHandler, WorkbenchPhase.BlockStartup); +registerWorkbenchContribution2(LanguageModelToolsExtensionPointHandler.ID, LanguageModelToolsExtensionPointHandler, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChatPromptFilesExtensionPointHandler.ID, ChatPromptFilesExtensionPointHandler, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChatCompatibilityNotifier.ID, ChatCompatibilityNotifier, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(CodeBlockActionRendering.ID, CodeBlockActionRendering, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChatCopyActionRendering.ID, ChatCopyActionRendering, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChatImplicitContextContribution.ID, ChatImplicitContextContribution, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(ChatViewsWelcomeHandler.ID, ChatViewsWelcomeHandler, WorkbenchPhase.BlockStartup); +registerWorkbenchContribution2(ChatGettingStartedContribution.ID, ChatGettingStartedContribution, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(ChatSetupContribution.ID, ChatSetupContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(HasByokModelsContribution.ID, HasByokModelsContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChatTeardownContribution.ID, ChatTeardownContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(ChatStatusBarEntry.ID, ChatStatusBarEntry, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(BuiltinToolsContribution.ID, BuiltinToolsContribution, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(UsagesToolContribution.ID, UsagesToolContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(RenameToolContribution.ID, RenameToolContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChatAgentSettingContribution.ID, ChatAgentSettingContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(ChatForegroundSessionCountContribution.ID, ChatForegroundSessionCountContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(ChatAgentActionsContribution.ID, ChatAgentActionsContribution, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(HookSchemaAssociationContribution.ID, HookSchemaAssociationContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(ToolReferenceNamesContribution.ID, ToolReferenceNamesContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(ChatAgentRecommendation.ID, ChatAgentRecommendation, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(ChatEditingEditorAccessibility.ID, ChatEditingEditorAccessibility, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(ChatQueuePickerRendering.ID, ChatQueuePickerRendering, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChatEditingEditorOverlay.ID, ChatEditingEditorOverlay, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(ChatEditingEditorContextKeys.ID, ChatEditingEditorContextKeys, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(ChatTransferContribution.ID, ChatTransferContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChatContextContributions.ID, ChatContextContributions, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(PromptUrlHandler.ID, PromptUrlHandler, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(PluginUrlHandler.ID, PluginUrlHandler, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChatEditingNotebookFileSystemProviderContrib.ID, ChatEditingNotebookFileSystemProviderContrib, WorkbenchPhase.BlockStartup); +registerWorkbenchContribution2(ChatResponseResourceWorkbenchContribution.ID, ChatResponseResourceWorkbenchContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(UserToolSetsContributions.ID, UserToolSetsContributions, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(PromptLanguageFeaturesProvider.ID, PromptLanguageFeaturesProvider, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(ChatWindowNotifier.ID, ChatWindowNotifier, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(ChatRepoInfoContribution.ID, ChatRepoInfoContribution, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(AgentPluginRecommendations.ID, AgentPluginRecommendations, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(PluginAutoUpdate.ID, PluginAutoUpdate, WorkbenchPhase.Eventually); + +registerChatActions(); +registerChatAccessibilityActions(); +registerChatCopyActions(); +registerChatOpenAgentDebugPanelAction(); +registerChatCodeBlockActions(); +registerChatCodeCompareBlockActions(); +registerChatFileTreeActions(); +registerChatPromptNavigationActions(); +registerChatTitleActions(); +registerChatExecuteActions(); +registerChatQueueActions(); +registerQuickChatActions(); +registerChatExportActions(); +registerMoveActions(); +registerNewChatActions(); +registerChatContextActions(); +registerChatDeveloperActions(); +registerChatEditorActions(); +registerChatElicitationActions(); +registerChatToolActions(); +registerLanguageModelActions(); +registerChatPluginActions(); +registerPlanReviewFeedbackEditorActions(); +registerAction2(ConfigureToolSets); +registerEditorFeature(ChatPasteProvidersFeature); + +agentPluginDiscoveryRegistry.register(new SyncDescriptor(ConfiguredAgentPluginDiscovery)); +agentPluginDiscoveryRegistry.register(new SyncDescriptor(MarketplaceAgentPluginDiscovery)); +agentPluginDiscoveryRegistry.register(new SyncDescriptor(ExtensionAgentPluginDiscovery)); +agentPluginDiscoveryRegistry.register(new SyncDescriptor(CopilotCliAgentPluginDiscovery)); + +registerSingleton(IChatResponseResourceFileSystemProvider, ChatResponseResourceFileSystemProvider, InstantiationType.Delayed); +registerSingleton(IChatTransferService, ChatTransferService, InstantiationType.Delayed); +registerSingleton(IChatService, ChatService, InstantiationType.Delayed); +registerSingleton(IChatWidgetService, ChatWidgetService, InstantiationType.Delayed); +registerSingleton(IQuickChatService, QuickChatService, InstantiationType.Delayed); +registerSingleton(IChatAccessibilityService, ChatAccessibilityService, InstantiationType.Delayed); +registerSingleton(IChatWidgetHistoryService, ChatWidgetHistoryService, InstantiationType.Delayed); +registerSingleton(ILanguageModelsConfigurationService, LanguageModelsConfigurationService, InstantiationType.Delayed); +registerSingleton(ILanguageModelsService, LanguageModelsService, InstantiationType.Delayed); +registerSingleton(ILanguageModelStatsService, LanguageModelStatsService, InstantiationType.Delayed); +registerSingleton(IChatSlashCommandService, ChatSlashCommandService, InstantiationType.Delayed); +registerSingleton(IChatAgentService, ChatAgentService, InstantiationType.Delayed); +registerSingleton(IChatAgentNameService, ChatAgentNameService, InstantiationType.Delayed); +registerSingleton(IChatVariablesService, ChatVariablesService, InstantiationType.Delayed); +registerSingleton(IAgentPluginService, AgentPluginService, InstantiationType.Delayed); +registerSingleton(IPluginMarketplaceService, PluginMarketplaceService, InstantiationType.Delayed); +registerSingleton(IWorkspacePluginSettingsService, WorkspacePluginSettingsService, InstantiationType.Delayed); +registerSingleton(IAgentPluginRepositoryService, AgentPluginRepositoryService, InstantiationType.Delayed); +registerSingleton(IPluginGitService, BrowserPluginGitCommandService, InstantiationType.Delayed); +registerSingleton(IPluginInstallService, PluginInstallService, InstantiationType.Delayed); +registerSingleton(ILanguageModelToolsService, LanguageModelToolsService, InstantiationType.Delayed); +registerSingleton(IToolResultCompressor, ToolResultCompressorService, InstantiationType.Delayed); +registerSingleton(ILanguageModelToolsConfirmationService, LanguageModelToolsConfirmationService, InstantiationType.Delayed); +registerSingleton(IChatToolRiskAssessmentService, ChatToolRiskAssessmentService, InstantiationType.Delayed); +registerSingleton(IVoiceChatService, VoiceChatService, InstantiationType.Delayed); +registerSingleton(IChatCodeBlockContextProviderService, ChatCodeBlockContextProviderService, InstantiationType.Delayed); +registerSingleton(ICodeMapperService, CodeMapperService, InstantiationType.Delayed); +registerSingleton(IChatEditingService, ChatEditingService, InstantiationType.Delayed); +registerSingleton(IChatMarkdownAnchorService, ChatMarkdownAnchorService, InstantiationType.Delayed); +registerSingleton(IAgentNetworkFilterService, AgentNetworkFilterService, InstantiationType.Delayed); +registerSingleton(ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService, InstantiationType.Delayed); +registerSingleton(IPromptsService, PromptsService, InstantiationType.Delayed); +registerSingleton(IChatContextPickService, ChatContextPickService, InstantiationType.Delayed); +registerSingleton(IChatModeService, ChatModeService, InstantiationType.Delayed); +registerSingleton(IChatAttachmentResolveService, ChatAttachmentResolveService, InstantiationType.Delayed); +registerSingleton(IChatAttachmentWidgetRegistry, ChatAttachmentWidgetRegistry, InstantiationType.Delayed); +registerSingleton(IChatTodoListService, ChatTodoListService, InstantiationType.Delayed); +registerSingleton(IChatArtifactsService, ChatArtifactsService, InstantiationType.Delayed); +registerSingleton(IChatOutputRendererService, ChatOutputRendererService, InstantiationType.Delayed); +registerSingleton(IChatLayoutService, ChatLayoutService, InstantiationType.Delayed); +registerSingleton(IPlanReviewFeedbackService, PlanReviewFeedbackService, InstantiationType.Delayed); +registerSingleton(IChatTipService, ChatTipService, InstantiationType.Delayed); +registerSingleton(IChatDebugService, ChatDebugServiceImpl, InstantiationType.Delayed); +registerSingleton(IChatImageCarouselService, ChatImageCarouselService, InstantiationType.Delayed); + +ChatWidget.CONTRIBS.push(ChatDynamicVariableModel); diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index fdca35be5025b..532c20d1cf1db 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -221,6 +221,7 @@ import './contrib/notebook/browser/notebook.contribution.js'; import './contrib/speech/browser/speech.contribution.js'; // Chat +import './contrib/chat/browser/chat.shared.contribution.js'; import './contrib/chat/browser/chat.contribution.js'; import './contrib/chat/browser/chat.view.contribution.js'; import './contrib/inlineChat/browser/inlineChat.contribution.js';