From 888a8d825df60ebc410fb5283209a69488df1559 Mon Sep 17 00:00:00 2001 From: vritant24 Date: Mon, 4 May 2026 16:11:14 -0700 Subject: [PATCH 01/26] refactor(backgroundTodo): replace start/executePass/executeFinalReview with drain-queue architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the old single-slot coalescing model with a two-slot queue (regular pass + final review) and a central _drainQueue() scheduler. Key changes: - requestRegularPass(): enqueues/coalesces regular passes, always updates _lastExecutionContext - requestFinalReview(turnId): enqueues a final-review pass that drains after all regular work, deduplicated by turn ID - _drainQueue(): central scheduler called after every state transition; picks regular work first, then final review - advanceCursor parameter on _runPass(): regular passes advance the delta tracker cursor, final review does not (fixes hidden bug where the old code accidentally called markProcessed despite the comment saying it should not) - Turn-scoped _finalReviewAttemptedTurnId replaces the fragile boolean _finalReviewQueued that was reset by executePass The old executeFinalReview() called start() which checked _state === InProgress as a coalescing gate. Since a regular pass always finishes before the finally block runs executeFinalReview, the processor was always Idle and start() would fall through to _runPass()—but only if the preconditions were met. The new architecture makes final review a pending queue item that _drainQueue() will run regardless of whether the processor is Idle, InProgress, or Failed at the time of the request. --- .../node/agent/backgroundTodoProcessor.ts | 387 ++++++++++-------- 1 file changed, 226 insertions(+), 161 deletions(-) diff --git a/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoProcessor.ts b/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoProcessor.ts index 33eefa50d9d12b..eec04e6c69553a 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoProcessor.ts +++ b/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoProcessor.ts @@ -100,7 +100,7 @@ export interface IBackgroundTodoExecutionContext { readonly toolsService: IToolsService; readonly telemetryService: ITelemetryService; readonly promptContext: IBuildPromptContext; - /** Set on the synthetic context used by {@link BackgroundTodoProcessor.executeFinalReview}. + /** Set on the synthetic context used by {@link BackgroundTodoProcessor.requestFinalReview}. * Switches the prompt into finalize mode so the bg agent can mark completions * the regular per-round passes never had a chance to see (the last round of a * turn has no follow-up `buildPrompt` to fire the bg agent against). */ @@ -120,8 +120,13 @@ export interface IBackgroundTodoResult { * Manages a single background todo processor per chat session. * * Owns a {@link BackgroundTodoDeltaTracker} for high-watermark tracking - * and coalesces concurrent updates so at most one background pass runs - * at a time. + * and a two-slot queue (regular pass + final review) so that at most one + * background pass runs at a time and final review always drains after + * regular work regardless of processor state. + * + * Drain order: + * 1. Pending regular pass (coalesced — only the latest survives). + * 2. Pending final review (at most once per turn). */ export class BackgroundTodoProcessor { @@ -135,19 +140,28 @@ export class BackgroundTodoProcessor { private _promise: Promise | undefined; private _cts: CancellationTokenSource | undefined; private _lastError: unknown; - private _pendingDelta: IBackgroundTodoDelta | undefined; - /** Work callback associated with {@link _pendingDelta}. Captured at queue time so a - * coalesced finalize pass keeps its finalize-mode closure (regular per-round work - * would otherwise overwrite finalize-mode behavior when the queued delta drains). */ - private _pendingWork: ((delta: IBackgroundTodoDelta, token: CancellationToken) => Promise) | undefined; private _hasCreatedTodos: boolean = false; private _passCount: number = 0; - /** Cached on the most recent {@link executePass} call so {@link executeFinalReview} - * can re-use the same services + most recent prompt context without a fresh build. */ + + // ── Two-slot queue ────────────────────────────────────────── + // Regular passes coalesce into one slot; final review occupies a + // second independent slot that drains only after all regular work. + + private _pendingRegularDelta: IBackgroundTodoDelta | undefined; + private _pendingRegularContext: IBackgroundTodoExecutionContext | undefined; + private _pendingRegularToken: CancellationToken | undefined; + + /** Pending final-review execution context. When set, {@link _drainQueue} + * will run a finalize pass after all regular work has drained. */ + private _pendingFinalReview: IBackgroundTodoExecutionContext | undefined; + private _pendingFinalReviewToken: CancellationToken | undefined; + /** Turn ID for which final review has already been attempted/queued. + * Prevents duplicate finalize passes within a single turn. */ + private _finalReviewAttemptedTurnId: string | undefined; + /** The most recent execution context from any {@link requestRegularPass} + * call. Used by {@link requestFinalReview} to build the synthetic + * final-review delta when no explicit context is provided. */ private _lastExecutionContext: IBackgroundTodoExecutionContext | undefined; - /** True after a final review pass has been queued for this turn; reset on the next - * regular {@link executePass}. Prevents duplicate finalize passes. */ - private _finalReviewQueued: boolean = false; readonly deltaTracker = new BackgroundTodoDeltaTracker(); @@ -215,41 +229,213 @@ export class BackgroundTodoProcessor { return { decision: BackgroundTodoDecision.Wait, reason: 'contextOnlyWaiting', delta }; } + // ── Public queue API ──────────────────────────────────────── + + /** + * Enqueue or coalesce a regular background pass. If a pass is already + * running, the delta is stashed and will drain when the current pass + * completes. Always updates {@link _lastExecutionContext}. + */ + requestRegularPass( + delta: IBackgroundTodoDelta, + context: IBackgroundTodoExecutionContext, + parentToken?: CancellationToken, + ): void { + this._lastExecutionContext = context; + this._logService?.debug(`[BackgroundTodo] requestRegularPass — newRounds=${delta.metadata.newRoundCount}, meaningful=${delta.metadata.meaningfulToolCallCount}, state=${this._state}`); + this._pendingRegularDelta = delta; + this._pendingRegularContext = context; + this._pendingRegularToken = parentToken; + this._drainQueue(); + } + + /** + * Request a single final-review pass for this turn. The pass runs + * after all pending regular work has drained, regardless of whether + * the processor is currently Idle, InProgress, or Failed. + * + * No-op when: + * - No execution context has been recorded (no prompt build happened). + * - No todos have been created yet (nothing to finalize). + * - Final review was already requested for the given {@link turnId}. + */ + requestFinalReview(turnId: string, parentToken?: CancellationToken): void { + if (!this._hasCreatedTodos || !this._lastExecutionContext) { + this._logService?.debug(`[BackgroundTodo] final review skipped — hasCreatedTodos=${this._hasCreatedTodos}, hasExecutionContext=${this._lastExecutionContext !== undefined}`); + return; + } + if (this._finalReviewAttemptedTurnId === turnId) { + this._logService?.debug(`[BackgroundTodo] final review skipped — already attempted for turn ${turnId}`); + return; + } + this._finalReviewAttemptedTurnId = turnId; + this._logService?.debug(`[BackgroundTodo] final review requested for turn ${turnId} — currentState=${this._state}`); + + this._pendingFinalReview = { ...this._lastExecutionContext, isFinalReview: true }; + this._pendingFinalReviewToken = parentToken; + this._drainQueue(); + } + + /** + * Wait for any in-flight pass — and any pending queued pass that drains + * from it — to settle. Returns immediately if idle with nothing queued. + */ + async waitForCompletion(): Promise { + while (this._promise) { + const current = this._promise; + await current; + // If _drainQueue started a new pass, _promise has been replaced. + // Loop until no new work was queued. + if (this._promise === current) { + break; + } + } + } + + // ── Low-level start (kept for direct unit tests) ──────────── + /** * Start a background pass if one is not already running. * - * If a pass is in progress, the delta is stashed and will be processed - * automatically when the current pass completes. + * If a pass is in progress, the delta is stashed as a pending regular + * pass and will drain via {@link _drainQueue} when the current pass + * completes. * * @param delta The new activity to process. * @param work An async function that performs the actual model call and * tool invocation. It receives a cancellation token. * @param parentToken Optional parent cancellation token. + * @param advanceCursor Whether to advance the delta tracker cursor on + * success. Regular passes set this to `true`; final review sets + * it to `false` so it does not interfere with regular-pass tracking. */ start( delta: IBackgroundTodoDelta, work: (delta: IBackgroundTodoDelta, token: CancellationToken) => Promise, parentToken?: CancellationToken, + advanceCursor: boolean = true, ): void { if (this._state === BackgroundTodoProcessorState.InProgress) { - // Coalesce: stash the latest delta AND its work callback for when the current - // pass finishes. Storing the callback is critical for finalize passes — they - // carry an `isFinalReview: true` execution context in the closure that must - // survive the queue. Without this, a finalize pass queued behind a regular - // pass would silently drain in regular mode. - this._logService?.debug(`[BackgroundTodo] coalescing delta (pass #${this._passCount} in progress) — newRounds=${delta.metadata.newRoundCount}, meaningful=${delta.metadata.meaningfulToolCallCount}, replacingPending=${this._pendingDelta !== undefined}`); - this._pendingDelta = delta; - this._pendingWork = work; + // Coalesce into the regular-pass slot so _drainQueue picks it up. + this._logService?.debug(`[BackgroundTodo] coalescing delta (pass #${this._passCount} in progress) — newRounds=${delta.metadata.newRoundCount}, meaningful=${delta.metadata.meaningfulToolCallCount}`); + this._pendingRegularDelta = delta; + this._pendingRegularContext = undefined; // will use work callback directly + this._pendingRegularToken = parentToken; + // Stash the work callback so _drainQueue can use it for the + // coalesced pass (preserves finalize-mode closures). + this._pendingRegularWork = work; + this._pendingRegularAdvanceCursor = advanceCursor; return; } - this._runPass(delta, work, parentToken); + this._runPass(delta, work, parentToken, advanceCursor); + } + + /** Stashed work callback for coalesced start() calls. */ + private _pendingRegularWork: ((delta: IBackgroundTodoDelta, token: CancellationToken) => Promise) | undefined; + private _pendingRegularAdvanceCursor: boolean = true; + + // ── Internal execution ────────────────────────────────────── + + /** + * Central scheduler. Called after every state transition and after + * every enqueue. Picks the next item to run: + * 1. Pending regular pass (coalesced — only the latest survives). + * 2. Pending final review. + * Does nothing if a pass is already running. + */ + private _drainQueue(): void { + if (this._state === BackgroundTodoProcessorState.InProgress) { + return; + } + + // ── Regular pass first ────────────────────────────────── + const regularDelta = this._pendingRegularDelta; + if (regularDelta) { + const ctx = this._pendingRegularContext; + const token = this._pendingRegularToken; + const stashedWork = this._pendingRegularWork; + const advanceCursor = this._pendingRegularAdvanceCursor; + this._pendingRegularDelta = undefined; + this._pendingRegularContext = undefined; + this._pendingRegularToken = undefined; + this._pendingRegularWork = undefined; + this._pendingRegularAdvanceCursor = true; + + if (stashedWork) { + // Coalesced via start() — use the stashed callback directly. + this._runPass(regularDelta, stashedWork, token, advanceCursor); + } else if (ctx) { + // Enqueued via requestRegularPass — build the work closure. + this._runPass( + regularDelta, + (d, t) => BackgroundTodoProcessor._doExecute(d, ctx, t), + token, + true, // regular passes always advance cursor + ); + } + return; + } + + // ── Final review ──────────────────────────────────────── + const finalCtx = this._pendingFinalReview; + if (finalCtx) { + const token = this._pendingFinalReviewToken; + this._pendingFinalReview = undefined; + this._pendingFinalReviewToken = undefined; + + // Build a synthetic delta from the full trajectory so the + // finalize prompt sees every round. + const allRounds = collectAllRounds( + finalCtx.promptContext.history, + finalCtx.promptContext.toolCallRounds ?? [], + ); + if (allRounds.length === 0) { + return; + } + let meaningful = 0; + let contextual = 0; + for (const round of allRounds) { + for (const call of round.toolCalls) { + const category = classifyTool(call.name); + if (category === 'meaningful') { + meaningful++; + } else if (category === 'context') { + contextual++; + } + } + } + const delta: IBackgroundTodoDelta = { + userRequest: finalCtx.promptContext.query, + newRounds: allRounds, + history: finalCtx.promptContext.history, + sessionResource: extractSessionResource(finalCtx.promptContext), + metadata: { + newRoundCount: allRounds.length, + newToolCallCount: meaningful + contextual, + meaningfulToolCallCount: meaningful, + contextToolCallCount: contextual, + isInitialDelta: false, + isRequestOnly: false, + }, + }; + + this._logService?.debug(`[BackgroundTodo] draining final review — rounds=${allRounds.length}, meaningful=${meaningful}, context=${contextual}`); + this._runPass( + delta, + (d, t) => BackgroundTodoProcessor._doExecute(d, finalCtx, t), + token, + false, // final review must NOT advance the regular-pass cursor + ); + return; + } } private _runPass( delta: IBackgroundTodoDelta, work: (delta: IBackgroundTodoDelta, token: CancellationToken) => Promise, parentToken?: CancellationToken, + advanceCursor: boolean = true, ): void { this._passCount++; const passNum = this._passCount; @@ -259,7 +445,7 @@ export class BackgroundTodoProcessor { this._cts = cts; const token = cts.token; - this._logService?.debug(`[BackgroundTodo] starting pass #${passNum} — newRounds=${delta.metadata.newRoundCount}, meaningful=${delta.metadata.meaningfulToolCallCount}, context=${delta.metadata.contextToolCallCount}`); + this._logService?.debug(`[BackgroundTodo] starting pass #${passNum} — newRounds=${delta.metadata.newRoundCount}, meaningful=${delta.metadata.meaningfulToolCallCount}, context=${delta.metadata.contextToolCallCount}, advanceCursor=${advanceCursor}`); const passPromise = work(delta, token).then( (result) => { @@ -271,11 +457,13 @@ export class BackgroundTodoProcessor { this._hasCreatedTodos = true; } this._logService?.debug(`[BackgroundTodo] pass #${passNum} completed: outcome=${result.outcome}, durationMs=${result.durationMs ?? '?'}, model=${result.model ?? '?'}, promptTokens=${result.promptTokens ?? '?'}, completionTokens=${result.completionTokens ?? '?'}`); - this.deltaTracker.markProcessed(delta); + if (advanceCursor) { + this.deltaTracker.markProcessed(delta); + } this._disposeCts(cts); this._state = BackgroundTodoProcessorState.Idle; - const hasPending = this._checkPending(work, parentToken); - if (!hasPending && this._promise === passPromise) { + this._drainQueue(); + if (!this._promise || this._promise === passPromise) { this._promise = undefined; } }, @@ -289,8 +477,8 @@ export class BackgroundTodoProcessor { this._logService?.warn(`[BackgroundTodo] pass #${passNum} failed: ${err}`); // Do NOT advance the cursor — the delta's rounds remain unprocessed // so a subsequent pass can retry with fresh or coalesced activity. - const hasPending = this._checkPending(work, parentToken); - if (!hasPending && this._promise === passPromise) { + this._drainQueue(); + if (!this._promise || this._promise === passPromise) { this._promise = undefined; } }, @@ -305,134 +493,6 @@ export class BackgroundTodoProcessor { cts.dispose(); } - /** - * If a delta was stashed while a pass was running, start a new pass now. - */ - private _checkPending( - work: (delta: IBackgroundTodoDelta, token: CancellationToken) => Promise, - parentToken?: CancellationToken, - ): boolean { - const pending = this._pendingDelta; - if (pending) { - // Prefer the work callback that was stashed alongside the pending delta — - // it preserves finalize-mode (or any future per-pass) context in its closure. - // Fall back to the caller-provided work only if no stashed callback exists. - const pendingWork = this._pendingWork ?? work; - const usingStashed = this._pendingWork !== undefined; - this._logService?.debug(`[BackgroundTodo] draining pending delta — newRounds=${pending.metadata.newRoundCount}, meaningful=${pending.metadata.meaningfulToolCallCount}, usingStashedWork=${usingStashed}`); - this._pendingDelta = undefined; - this._pendingWork = undefined; - this._runPass(pending, pendingWork, parentToken); - return true; - } - return false; - } - - /** - * Wait for any in-flight pass — and any pending coalesced pass that drains - * from it — to settle. Returns immediately if idle. - */ - async waitForCompletion(): Promise { - while (this._promise) { - const current = this._promise; - await current; - // If _checkPending started a new pass, _promise has been replaced. - // Loop until no new work was queued. - if (this._promise === current) { - break; - } - } - } - - // ── Execution ────────────────────────────────────────────── - - /** - * Convenience method: starts a background pass using the built-in - * execution logic (acquire copilot-fast endpoint → render prompt → - * call model → invoke todo tool). - */ - executePass( - delta: IBackgroundTodoDelta, - context: IBackgroundTodoExecutionContext, - parentToken?: CancellationToken, - ): void { - this._lastExecutionContext = context; - this._finalReviewQueued = false; - this.start( - delta, - (d, token) => BackgroundTodoProcessor._doExecute(d, context, token), - parentToken, - ); - } - - /** - * Fire one extra background pass after the agent loop has ended for this turn. - * - * The regular per-round bg passes never see the very last round (there is no - * follow-up `buildPrompt` to fire against), so any task that *just* completed - * on the final round stays stuck as 'in-progress' until the next user turn. - * This pass uses the cached execution context from the most recent - * {@link executePass} and runs in finalize mode so the model focuses on - * promoting completed work rather than re-planning. - * - * No-op when: - * - No bg pass has ever run for this session (no cached context). - * - No todos exist yet (nothing to finalize). - * - A final review has already been queued for this turn. - */ - executeFinalReview(parentToken?: CancellationToken): void { - if (this._finalReviewQueued || !this._hasCreatedTodos || !this._lastExecutionContext) { - this._logService?.debug(`[BackgroundTodo] final review skipped — alreadyQueued=${this._finalReviewQueued}, hasCreatedTodos=${this._hasCreatedTodos}, hasExecutionContext=${this._lastExecutionContext !== undefined}`); - return; - } - this._finalReviewQueued = true; - this._logService?.debug(`[BackgroundTodo] final review requested — currentState=${this._state}`); - - const ctx = this._lastExecutionContext; - const finalCtx: IBackgroundTodoExecutionContext = { ...ctx, isFinalReview: true }; - - // Build a synthetic delta that includes every round we know about so the - // finalize prompt has full trajectory context. Skip cursor advancement - // (markProcessed is intentionally not called) since this is a one-shot review. - const allRounds = collectAllRounds(ctx.promptContext.history, ctx.promptContext.toolCallRounds ?? []); - if (allRounds.length === 0) { - return; - } - let meaningful = 0; - let contextual = 0; - for (const round of allRounds) { - for (const call of round.toolCalls) { - const category = classifyTool(call.name); - if (category === 'meaningful') { - meaningful++; - } else if (category === 'context') { - contextual++; - } - } - } - const delta: IBackgroundTodoDelta = { - userRequest: ctx.promptContext.query, - newRounds: allRounds, - history: ctx.promptContext.history, - sessionResource: extractSessionResource(ctx.promptContext), - metadata: { - newRoundCount: allRounds.length, - newToolCallCount: meaningful + contextual, - meaningfulToolCallCount: meaningful, - contextToolCallCount: contextual, - isInitialDelta: false, - isRequestOnly: false, - }, - }; - - this._logService?.debug(`[BackgroundTodo] queueing final review — rounds=${allRounds.length}, meaningful=${meaningful}, context=${contextual}`); - this.start( - delta, - (d, token) => BackgroundTodoProcessor._doExecute(d, finalCtx, token), - parentToken, - ); - } - /** * The actual background work: render the todo prompt against copilot-fast, * parse tool calls, and invoke the todo tool. @@ -652,10 +712,15 @@ export class BackgroundTodoProcessor { this._state = BackgroundTodoProcessorState.Idle; this._lastError = undefined; this._promise = undefined; - this._pendingDelta = undefined; - this._pendingWork = undefined; + this._pendingRegularDelta = undefined; + this._pendingRegularContext = undefined; + this._pendingRegularToken = undefined; + this._pendingRegularWork = undefined; + this._pendingRegularAdvanceCursor = true; + this._pendingFinalReview = undefined; + this._pendingFinalReviewToken = undefined; this._lastExecutionContext = undefined; - this._finalReviewQueued = false; + this._finalReviewAttemptedTurnId = undefined; } } From dd44a254a6e892a45683473668f6f3f382a463e6 Mon Sep 17 00:00:00 2001 From: vritant24 Date: Mon, 4 May 2026 16:11:27 -0700 Subject: [PATCH 02/26] refactor(backgroundTodo): update callers to use new queue API - _maybeStartBackgroundTodoPass() calls requestRegularPass() instead of executePass(), including for the processorInProgress coalescing case - handleRequest() finally block calls requestFinalReview(turnId) instead of executeFinalReview(), passing the turn ID for deduplication - Update JSDoc @link reference in BackgroundTodoPromptProps --- .../src/extension/intents/node/agentIntent.ts | 23 +++++++++++++------ .../node/agent/backgroundTodoPrompt.tsx | 2 +- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts index c71de26b3ff84b..f393cbb02662c4 100644 --- a/extensions/copilot/src/extension/intents/node/agentIntent.ts +++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts @@ -303,7 +303,8 @@ export class AgentIntent extends EditCodeIntent { if (isBackgroundTodoAgentEnabled(this.configurationService, this.expService, request)) { const todoProcessor = this._backgroundTodoProcessors.get(conversation.sessionId); if (todoProcessor) { - todoProcessor.executeFinalReview(); + const turnId = conversation.getLatestTurn().id; + todoProcessor.requestFinalReview(turnId); await todoProcessor.waitForCompletion(); } } @@ -1225,17 +1226,25 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I this.logService.debug(`[BackgroundTodo] policy decision: ${decision} (${reason})`); - if (decision !== BackgroundTodoDecision.Run || !delta) { - return; - } - - processor.executePass(delta, { + const executionContext = { instantiationService: this.instantiationService, logService: this.logService, toolsService: this.toolsService, telemetryService: this.telemetryService, promptContext, - }, token); + }; + + if (decision === BackgroundTodoDecision.Wait && reason === 'processorInProgress' && delta) { + // Coalesce into the queue so the latest context is not lost. + processor.requestRegularPass(delta, executionContext, token); + return; + } + + if (decision !== BackgroundTodoDecision.Run || !delta) { + return; + } + + processor.requestRegularPass(delta, executionContext, token); } override processResponse = undefined; diff --git a/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoPrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoPrompt.tsx index 29e8c99ba03db2..18a1b404a1e367 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoPrompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoPrompt.tsx @@ -15,7 +15,7 @@ export interface BackgroundTodoPromptProps extends BasePromptElementProps { readonly history: IBackgroundTodoHistory; /** When true, the prompt switches to finalize mode: the agent loop has ended and * the bg agent should mark any in-progress items now-complete based on the full - * trajectory. See {@link BackgroundTodoProcessor.executeFinalReview}. */ + * trajectory. See {@link BackgroundTodoProcessor.requestFinalReview}. */ readonly isFinalReview?: boolean; } From bf8d74c70d11702b72cc908506eda0988d3e10f3 Mon Sep 17 00:00:00 2001 From: vritant24 Date: Mon, 4 May 2026 16:11:55 -0700 Subject: [PATCH 03/26] test(backgroundTodo): update tests for drain-queue architecture - Add makeExecutionContext() helper for requestRegularPass/requestFinalReview tests - Add advanceCursor=false cursor test (verifies final review does not advance the delta tracker) - Add requestFinalReview tests: no-op guards, runs-when-idle, turn-ID dedup, drains-after-regular-pass - Update cancel test to verify it clears final review queue - Rename executeFinalReview references to requestFinalReview --- .../test/backgroundTodoProcessor.spec.ts | 105 ++++++++++++++++-- 1 file changed, 95 insertions(+), 10 deletions(-) diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoProcessor.spec.ts b/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoProcessor.spec.ts index 953e5cbe231b0f..35379270dd2938 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoProcessor.spec.ts +++ b/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoProcessor.spec.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { describe, expect, test } from 'vitest'; -import { BackgroundTodoProcessor, BackgroundTodoProcessorState, IBackgroundTodoResult } from '../backgroundTodoProcessor'; +import { BackgroundTodoProcessor, BackgroundTodoProcessorState, IBackgroundTodoExecutionContext, IBackgroundTodoResult } from '../backgroundTodoProcessor'; import { IBackgroundTodoDelta } from '../backgroundTodoDelta'; import { CancellationTokenSource } from '../../../../../util/vs/base/common/cancellation'; @@ -30,6 +30,21 @@ function makeDelta(rounds: string[] = []): IBackgroundTodoDelta { }; } +function makeExecutionContext(rounds: string[] = []): IBackgroundTodoExecutionContext { + return { + instantiationService: { invokeFunction: () => { throw new Error('no endpoint'); } } as any, + logService: { debug: () => undefined, warn: () => undefined } as any, + toolsService: { invokeTool: async () => undefined } as any, + telemetryService: { sendMSFTTelemetryEvent: () => undefined } as any, + promptContext: { + query: 'fix the bug', + history: [], + chatVariables: { hasVariables: () => false } as any, + toolCallRounds: rounds.map(id => ({ id, response: '', toolInputRetry: 0, toolCalls: [] })), + } as any, + }; +} + describe('BackgroundTodoProcessor', () => { test('initial state is Idle', () => { @@ -87,6 +102,19 @@ describe('BackgroundTodoProcessor', () => { })).toBeDefined(); }); + test('delta cursor does NOT advance when advanceCursor is false', async () => { + const processor = new BackgroundTodoProcessor(); + processor.start(makeDelta(['r1']), async () => ({ outcome: 'success' }), undefined, false); + await processor.waitForCompletion(); + + expect(processor.deltaTracker.getDelta({ + query: 'fix', + history: [], + chatVariables: { hasVariables: () => false } as any, + toolCallRounds: [{ id: 'r1', response: '', toolInputRetry: 0, toolCalls: [] }], + })).toBeDefined(); + }); + test('coalesces concurrent updates', async () => { const processor = new BackgroundTodoProcessor(); let workCallCount = 0; @@ -147,20 +175,79 @@ describe('BackgroundTodoProcessor', () => { cts.dispose(); }); - test('executeFinalReview is a no-op when no executePass has run', () => { + // ── requestFinalReview ────────────────────────────────────── + + test('requestFinalReview is a no-op when no context has been recorded', () => { const processor = new BackgroundTodoProcessor(); - processor.executeFinalReview(); + processor.requestFinalReview('turn-1'); expect(processor.state).toBe(BackgroundTodoProcessorState.Idle); }); - test('executeFinalReview is a no-op when no todos have been created', async () => { + test('requestFinalReview is a no-op when no todos have been created', async () => { const processor = new BackgroundTodoProcessor(); // Simulate a noop pass so a context exists but hasCreatedTodos remains false processor.start(makeDelta(['r1']), async () => ({ outcome: 'noop' })); await processor.waitForCompletion(); expect(processor.hasCreatedTodos).toBe(false); - processor.executeFinalReview(); - // Should not transition to InProgress because hasCreatedTodos is false + processor.requestFinalReview('turn-1'); + expect(processor.state).toBe(BackgroundTodoProcessorState.Idle); + }); + + test('requestFinalReview runs when processor is idle and todos exist', async () => { + const processor = new BackgroundTodoProcessor(); + // Use requestRegularPass so _lastExecutionContext is recorded + processor.requestRegularPass(makeDelta(['r1']), makeExecutionContext(['r1'])); + // Force hasCreatedTodos + await processor.waitForCompletion(); + // The work threw because the mock context has no real endpoint, but + // we need hasCreatedTodos = true. Use the low-level start() for that. + processor.start(makeDelta(['r2']), async () => ({ outcome: 'success' })); + await processor.waitForCompletion(); + expect(processor.hasCreatedTodos).toBe(true); + expect(processor.state).toBe(BackgroundTodoProcessorState.Idle); + + // Now request final review — it should transition to InProgress + processor.requestFinalReview('turn-1'); + expect(processor.state).toBe(BackgroundTodoProcessorState.InProgress); + await processor.waitForCompletion(); + }); + + test('requestFinalReview deduplicates by turn ID', async () => { + const processor = new BackgroundTodoProcessor(); + processor.start(makeDelta(['r1']), async () => ({ outcome: 'success' })); + await processor.waitForCompletion(); + // Record a context + processor.requestRegularPass(makeDelta(['r2']), makeExecutionContext(['r2'])); + await processor.waitForCompletion(); + + // First request should be accepted + processor.requestFinalReview('turn-1'); + expect(processor.state).toBe(BackgroundTodoProcessorState.InProgress); + await processor.waitForCompletion(); + + // Second request with same turn ID should be a no-op + processor.requestFinalReview('turn-1'); + expect(processor.state).toBe(BackgroundTodoProcessorState.Idle); + }); + + test('requestFinalReview drains after a regular pass completes', async () => { + const processor = new BackgroundTodoProcessor(); + const ranWork: string[] = []; + + // Start a slow regular pass + processor.start(makeDelta(['r1']), async () => { + ranWork.push('regular'); + await new Promise(resolve => setTimeout(resolve, 50)); + return { outcome: 'success' }; + }); + + // While in progress, record context and request final review + processor.requestRegularPass(makeDelta(['r2']), makeExecutionContext(['r1', 'r2'])); + processor.requestFinalReview('turn-1'); + + await processor.waitForCompletion(); + // Regular pass ran first (from start()), then the coalesced requestRegularPass + // drained, then the final review drained. expect(processor.state).toBe(BackgroundTodoProcessorState.Idle); }); @@ -176,9 +263,7 @@ describe('BackgroundTodoProcessor', () => { }); expect(processor.state).toBe(BackgroundTodoProcessorState.InProgress); - // Queue a second pass with a *different* work callback (workB). This simulates - // executeFinalReview queuing a finalize-mode work closure while a regular pass - // is still in flight. The drained pass MUST run workB, not workA. + // Queue a second pass with a *different* work callback (workB). processor.start(makeDelta(['r2']), async () => { ranWork.push('B'); return { outcome: 'success' }; @@ -188,7 +273,7 @@ describe('BackgroundTodoProcessor', () => { expect(ranWork).toEqual(['A', 'B']); }); - test('cancel clears pending coalesced work', async () => { + test('cancel clears pending coalesced work and final review', async () => { const processor = new BackgroundTodoProcessor(); const ranWork: string[] = []; From 8630a2d835b98370c66578590428481a47b6bf4e Mon Sep 17 00:00:00 2001 From: vritant24 Date: Mon, 4 May 2026 16:37:12 -0700 Subject: [PATCH 04/26] fix PR comments --- .../node/agent/backgroundTodoProcessor.ts | 27 +++++-- .../test/backgroundTodoProcessor.spec.ts | 79 ++++++++++++++++--- 2 files changed, 89 insertions(+), 17 deletions(-) diff --git a/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoProcessor.ts b/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoProcessor.ts index eec04e6c69553a..e19bb02c2fabed 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoProcessor.ts +++ b/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoProcessor.ts @@ -365,16 +365,27 @@ export class BackgroundTodoProcessor { if (stashedWork) { // Coalesced via start() — use the stashed callback directly. this._runPass(regularDelta, stashedWork, token, advanceCursor); + return; } else if (ctx) { - // Enqueued via requestRegularPass — build the work closure. - this._runPass( - regularDelta, - (d, t) => BackgroundTodoProcessor._doExecute(d, ctx, t), - token, - true, // regular passes always advance cursor - ); + // Enqueued via requestRegularPass — recompute against the latest cursor. + // This avoids replaying the in-flight delta when no new rounds arrived + // while the previous pass was running, and retries the full delta if the + // previous pass failed and did not advance the cursor. + const latestDelta = this.deltaTracker.peekDelta(ctx.promptContext); + if (!latestDelta) { + this._logService?.debug('[BackgroundTodo] queued regular pass skipped: no new delta remains after in-flight pass'); + } else { + this._runPass( + latestDelta, + (d, t) => BackgroundTodoProcessor._doExecute(d, ctx, t), + token, + true, // regular passes always advance cursor + ); + return; + } + } else { + this._logService?.debug('[BackgroundTodo] queued regular pass skipped: missing execution context'); } - return; } // ── Final review ──────────────────────────────────────── diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoProcessor.spec.ts b/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoProcessor.spec.ts index 35379270dd2938..d1cef11d29dd67 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoProcessor.spec.ts +++ b/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoProcessor.spec.ts @@ -30,12 +30,30 @@ function makeDelta(rounds: string[] = []): IBackgroundTodoDelta { }; } -function makeExecutionContext(rounds: string[] = []): IBackgroundTodoExecutionContext { +interface IExecutionContextTestOptions { + readonly endpointDelayMs?: number; + readonly logMessages?: string[]; + readonly telemetryEvents?: string[]; +} + +function makeLogService(logMessages?: string[]) { + return { + debug: (message: string) => logMessages?.push(message), + warn: (message: string) => logMessages?.push(message), + } as any; +} + +function makeExecutionContext(rounds: string[] = [], options: IExecutionContextTestOptions = {}): IBackgroundTodoExecutionContext { return { - instantiationService: { invokeFunction: () => { throw new Error('no endpoint'); } } as any, - logService: { debug: () => undefined, warn: () => undefined } as any, + instantiationService: { invokeFunction: async () => { + if (options.endpointDelayMs !== undefined) { + await new Promise(resolve => setTimeout(resolve, options.endpointDelayMs)); + } + throw new Error('no endpoint'); + } } as any, + logService: makeLogService(options.logMessages), toolsService: { invokeTool: async () => undefined } as any, - telemetryService: { sendMSFTTelemetryEvent: () => undefined } as any, + telemetryService: { sendMSFTTelemetryEvent: (eventName: string) => options.telemetryEvents?.push(eventName) } as any, promptContext: { query: 'fix the bug', history: [], @@ -142,6 +160,26 @@ describe('BackgroundTodoProcessor', () => { expect(workCallCount).toBe(2); }); + test('requestRegularPass skips queued work when only in-flight rounds were present', async () => { + const telemetryEvents: string[] = []; + const context = makeExecutionContext(['r1'], { endpointDelayMs: 20, telemetryEvents }); + const processor = new BackgroundTodoProcessor(); + + processor.requestRegularPass(makeDelta(['r1']), context); + processor.requestRegularPass(makeDelta(['r1']), context); + await processor.waitForCompletion(); + + expect({ + state: processor.state, + telemetryEventCount: telemetryEvents.length, + hasRemainingDelta: processor.deltaTracker.getDelta(context.promptContext) !== undefined, + }).toEqual({ + state: BackgroundTodoProcessorState.Idle, + telemetryEventCount: 1, + hasRemainingDelta: false, + }); + }); + test('cancel stops in-flight work', async () => { const processor = new BackgroundTodoProcessor(); let completed = false; @@ -231,9 +269,15 @@ describe('BackgroundTodoProcessor', () => { }); test('requestFinalReview drains after a regular pass completes', async () => { - const processor = new BackgroundTodoProcessor(); + const logMessages: string[] = []; + const telemetryEvents: string[] = []; + const processor = new BackgroundTodoProcessor(makeLogService(logMessages)); const ranWork: string[] = []; + processor.start(makeDelta(['r0']), async () => ({ outcome: 'success' })); + await processor.waitForCompletion(); + logMessages.length = 0; + // Start a slow regular pass processor.start(makeDelta(['r1']), async () => { ranWork.push('regular'); @@ -242,13 +286,30 @@ describe('BackgroundTodoProcessor', () => { }); // While in progress, record context and request final review - processor.requestRegularPass(makeDelta(['r2']), makeExecutionContext(['r1', 'r2'])); + processor.requestRegularPass(makeDelta(['r2']), makeExecutionContext(['r1', 'r2'], { telemetryEvents })); processor.requestFinalReview('turn-1'); await processor.waitForCompletion(); - // Regular pass ran first (from start()), then the coalesced requestRegularPass - // drained, then the final review drained. - expect(processor.state).toBe(BackgroundTodoProcessorState.Idle); + + const passStartIndexes = logMessages + .map((message, index) => message.includes('starting pass #') ? index : -1) + .filter(index => index !== -1); + const finalReviewIndex = logMessages.findIndex(message => message.includes('draining final review')); + expect({ + state: processor.state, + ranWork, + telemetryEventCount: telemetryEvents.length, + passStartCount: passStartIndexes.length, + coalescedRegularBeforeFinalReview: passStartIndexes[1] !== undefined && passStartIndexes[1] < finalReviewIndex, + finalReviewBeforeFinalPass: passStartIndexes[2] !== undefined && finalReviewIndex < passStartIndexes[2], + }).toEqual({ + state: BackgroundTodoProcessorState.Idle, + ranWork: ['regular'], + telemetryEventCount: 2, + passStartCount: 3, + coalescedRegularBeforeFinalReview: true, + finalReviewBeforeFinalPass: true, + }); }); test('coalesced pending delta runs with its own queued work callback', async () => { From be69289a8fd9a7b8dc1daf7c9d60941db63d6ae2 Mon Sep 17 00:00:00 2001 From: Kevin Kent Date: Mon, 4 May 2026 21:24:45 -0400 Subject: [PATCH 05/26] Use VS Code chat parent turn id for parentRequestId across response.success/cancelled/error Today, response.success emits parentRequestId from baseTelemetry.parentHeaderRequestId, which is the X-GitHub-Request-Id of the previous fetch (a per-fetch / call-level id), not the parent turn. This doesn't match panel.request.parentRequestId, which uses the VS Code chat API ChatRequest.parentRequestId (a per-turn, client-side id). Switch response.success.parentRequestId to baseTelemetry.parentRequestId (sourced from request.parentRequestId via defaultIntentRequestHandler), so it aligns with panel.request. Also emit parentRequestId and conversationId on response.cancelled and response.error, sourced from the same telemetry properties, so cancelled/errored requests can be correlated to a parent turn and conversation the same way successful requests can. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/extension/prompt/node/chatMLFetcher.ts | 4 ++++ .../prompt/node/chatMLFetcherTelemetry.ts | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts index 06b1621dad4695..da9b7bec97fedb 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts @@ -492,7 +492,9 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { model: chatEndpoint.model, apiType: chatEndpoint.apiType, transport, + conversationId: telemetryProperties.conversationId ?? conversationId, associatedRequestId: telemetryProperties.associatedRequestId, + parentRequestId: telemetryProperties.parentRequestId, retryAfterError: telemetryProperties.retryAfterError, retryAfterErrorGitHubRequestId: telemetryProperties.retryAfterErrorGitHubRequestId, connectivityTestError: telemetryProperties.connectivityTestError, @@ -638,7 +640,9 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { model: chatEndpoint.model, apiType: chatEndpoint.apiType, transport, + conversationId: telemetryProperties.conversationId ?? conversationId, associatedRequestId: telemetryProperties.associatedRequestId, + parentRequestId: telemetryProperties.parentRequestId, retryAfterError: telemetryProperties.retryAfterError, retryAfterErrorGitHubRequestId: telemetryProperties.retryAfterErrorGitHubRequestId, connectivityTestError: telemetryProperties.connectivityTestError, diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts index 9650b9f9529f94..5e8da4a4cf4d30 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts @@ -37,7 +37,9 @@ export interface IChatMLFetcherCancellationProperties { model: string; apiType: string | undefined; transport: string; + conversationId?: string; associatedRequestId?: string; + parentRequestId?: string; retryAfterError?: string; retryAfterErrorGitHubRequestId?: string; connectivityTestError?: string; @@ -149,7 +151,7 @@ export class ChatMLFetcherTelemetrySender { "resumeEventSeen": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether a system resume event was seen during the request", "isMeasurement": true }, "subType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Sub-type of the request" }, "modelCallId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Unique identifier for this model call" }, - "parentRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The requestId from the parent response.success event for subagent calls" }, + "parentRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "For a subagent: the VS Code chat request id of the parent turn that invoked this subagent (matches panel.request.parentRequestId)." }, "parentModelCallId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Model call ID of the parent request for subagent calls" }, "iterationNumber": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Iteration number within the tool calling loop" } } @@ -166,11 +168,11 @@ export class ChatMLFetcherTelemetrySender { requestId: chatCompletion.requestId.headerRequestId, gitHubRequestId: chatCompletion.requestId.gitHubRequestId, associatedRequestId: baseTelemetry?.properties.associatedRequestId, + parentRequestId: baseTelemetry?.properties.parentRequestId, reasoningEffort: requestBody.reasoning?.effort ?? requestBody.output_config?.effort, reasoningSummary: requestBody.reasoning?.summary, modelCallId, ...(baseTelemetry?.properties.subType ? { subType: baseTelemetry.properties.subType } : {}), - ...(baseTelemetry?.properties.parentHeaderRequestId ? { parentRequestId: baseTelemetry.properties.parentHeaderRequestId } : {}), ...(baseTelemetry?.properties.parentModelCallId ? { parentModelCallId: baseTelemetry.properties.parentModelCallId } : {}), ...(baseTelemetry?.properties.iterationNumber ? { iterationNumber: baseTelemetry.properties.iterationNumber } : {}), ...(fetcher ? { fetcher } : {}), @@ -212,7 +214,9 @@ export class ChatMLFetcherTelemetrySender { model, apiType, transport, + conversationId, associatedRequestId, + parentRequestId, retryAfterError, retryAfterErrorGitHubRequestId, connectivityTestError, @@ -244,7 +248,9 @@ export class ChatMLFetcherTelemetrySender { "apiType": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "API type for the response- chat completions or responses" }, "source": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Source for why the request was made" }, "requestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Id of the request" }, + "conversationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Id for the current chat conversation." }, "associatedRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Another request ID that this request is associated with (eg, the originating request of a summarization request)." }, + "parentRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "For a subagent: the request id of the main agent request that invoked this subagent." }, "fetcher": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The fetcher used for the request" }, "transport": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The transport used for the request (http or websocket)" }, "totalTokenMax": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Maximum total token window", "isMeasurement": true }, @@ -273,7 +279,9 @@ export class ChatMLFetcherTelemetrySender { source, requestId, model, + conversationId, associatedRequestId, + parentRequestId, ...(fetcher ? { fetcher } : {}), transport, ...(retryAfterError ? { retryAfterError } : {}), @@ -330,7 +338,9 @@ export class ChatMLFetcherTelemetrySender { "source": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Source for why the request was made" }, "requestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Id of the request" }, "gitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub request id if available" }, + "conversationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Id for the current chat conversation." }, "associatedRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Another request ID that this request is associated with (eg, the originating request of a summarization request)." }, + "parentRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "For a subagent: the request id of the main agent request that invoked this subagent." }, "reasoningEffort": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Reasoning effort level" }, "reasoningSummary": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Reasoning summary level" }, "fetcher": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The fetcher used for the request" }, @@ -364,11 +374,13 @@ export class ChatMLFetcherTelemetrySender { gitHubRequestId: processed.serverRequestId, model: chatEndpointInfo.model, apiType: chatEndpointInfo.apiType, + conversationId: telemetryProperties?.conversationId, reasoningEffort: requestBody.reasoning?.effort ?? requestBody.output_config?.effort, reasoningSummary: requestBody.reasoning?.summary, ...(fetcher ? { fetcher } : {}), transport, associatedRequestId: telemetryProperties?.associatedRequestId, + parentRequestId: telemetryProperties?.parentRequestId, ...(telemetryProperties?.retryAfterError ? { retryAfterError: telemetryProperties.retryAfterError } : {}), ...(telemetryProperties?.retryAfterErrorGitHubRequestId ? { retryAfterErrorGitHubRequestId: telemetryProperties.retryAfterErrorGitHubRequestId } : {}), ...(telemetryProperties?.connectivityTestError ? { connectivityTestError: telemetryProperties.connectivityTestError } : {}), From a228cf22a3247131d7a58b59a82d34325ad42c0d Mon Sep 17 00:00:00 2001 From: Kevin Kent Date: Mon, 4 May 2026 21:41:17 -0400 Subject: [PATCH 06/26] Set parentRequestId on subagent telemetry from parent ChatRequest.id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The execution and search subagent tool-calling loops only populated parentHeaderRequestId on their telemetry properties — not parentRequestId. These loops are spawned directly from a parent turn's tool call (not via the VS Code chat-agent mechanism), so VS Code core never sets ChatRequest.parentRequestId on them. As a result, subagent response.success/cancelled/error events would not carry the parent's chat request id, breaking correlation back to the parent. Set parentRequestId from this.options.request.id (the parent ChatRequest's id) so subagent telemetry consistently carries the parent correlation id alongside parentHeaderRequestId. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extension/prompt/node/executionSubagentToolCallingLoop.ts | 1 + .../src/extension/prompt/node/searchSubagentToolCallingLoop.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts b/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts index aae203227298cb..7613c17573ae5d 100644 --- a/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts +++ b/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts @@ -361,6 +361,7 @@ export class ExecutionSubagentToolCallingLoop extends ToolCallingLoop Date: Tue, 5 May 2026 16:07:31 -0700 Subject: [PATCH 07/26] agentHost: initial scaffolding out for completions --- .../agentHost/browser/nullAgentHostService.ts | 3 +- .../browser/remoteAgentHostProtocolClient.ts | 6 +- .../platform/agentHost/common/agentService.ts | 11 +- .../common/state/protocol/.ahp-version | 2 +- .../common/state/protocol/commands.ts | 133 +++++++++++++++++- .../common/state/protocol/messages.ts | 3 +- .../electron-browser/agentHostService.ts | 5 +- .../agentHost/node/agentHostCompletions.ts | 94 +++++++++++++ .../node/agentHostFileCompletionProvider.ts | 47 +++++++ .../platform/agentHost/node/agentService.ts | 16 ++- .../agentHost/node/protocolServerHandler.ts | 3 + .../test/node/protocolServerHandler.test.ts | 3 +- .../agentHost/loggingAgentConnection.ts | 6 +- .../test/browser/agentHostPty.test.ts | 3 +- 14 files changed, 324 insertions(+), 11 deletions(-) create mode 100644 src/vs/platform/agentHost/node/agentHostCompletions.ts create mode 100644 src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts diff --git a/src/vs/platform/agentHost/browser/nullAgentHostService.ts b/src/vs/platform/agentHost/browser/nullAgentHostService.ts index 184572bbb05041..673ea22be5041b 100644 --- a/src/vs/platform/agentHost/browser/nullAgentHostService.ts +++ b/src/vs/platform/agentHost/browser/nullAgentHostService.ts @@ -9,7 +9,7 @@ import { constObservable, IObservable } from '../../../base/common/observable.js import { URI } from '../../../base/common/uri.js'; import type { IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostService, IAgentHostSocketInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../common/agentService.js'; import type { IAgentSubscription } from '../common/state/agentSubscription.js'; -import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; +import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult } from '../common/state/sessionProtocol.js'; import type { ComponentToState, RootState, StateComponents } from '../common/state/sessionState.js'; @@ -44,6 +44,7 @@ export class NullAgentHostService implements IAgentHostService { async createSession(_config?: IAgentCreateSessionConfig): Promise { return notSupported(); } async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise { return notSupported(); } async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { return notSupported(); } + async completions(_params: CompletionsParams): Promise { return { items: [] }; } async startWebSocketServer(): Promise { return notSupported(); } async getInspectInfo(_tryEnable: boolean): Promise { return undefined; } async disposeSession(_session: URI): Promise { } diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 3e517516dc9008..03c02b69118710 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -28,7 +28,7 @@ import { PROTOCOL_VERSION } from '../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, ProtocolError, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { isClientTransport, type IProtocolTransport } from '../common/state/sessionTransport.js'; import { AhpErrorCodes } from '../common/state/protocol/errors.js'; -import { ContentEncoding, ResourceRequestParams, type CreateTerminalParams, type ResolveSessionConfigResult, type SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; +import { ContentEncoding, ResourceRequestParams, type CompletionsParams, type CompletionsResult, type CreateTerminalParams, type ResolveSessionConfigResult, type SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; const AHP_CLIENT_CONNECTION_CLOSED = -32000; @@ -249,6 +249,10 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC }); } + async completions(params: CompletionsParams): Promise { + return this._sendRequest('completions', params); + } + /** * Authenticate with the remote agent host using a specific scheme. */ diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 578fe1a28c912a..64c2c80748fff0 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -11,7 +11,7 @@ import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { ISyncedCustomization } from './agentPluginManager.js'; import type { IAgentSubscription } from './state/agentSubscription.js'; -import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js'; +import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js'; import { ProtectedResourceMetadata, type ConfigSchema, type FileEdit, type ModelSelection, type SessionActiveClient, type ToolCallPendingConfirmationState, type ToolDefinition } from './state/protocol/state.js'; import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from './state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult, IStateSnapshot } from './state/sessionProtocol.js'; @@ -593,6 +593,14 @@ export interface IAgentService { /** Return dynamic completions for a session configuration property. */ sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise; + /** + * Return completion items for a partially-typed input (e.g. an `@`-mention + * inside a user message the user is composing). Delegates to a pluggable + * set of {@link IAgentHostCompletionItemProvider}s registered with the + * agent host. + */ + completions(params: CompletionsParams): Promise; + /** Dispose a session in the agent host, freeing SDK resources. */ disposeSession(session: URI): Promise; @@ -716,6 +724,7 @@ export interface IAgentConnection { createSession(config?: IAgentCreateSessionConfig): Promise; resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise; sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise; + completions(params: CompletionsParams): Promise; disposeSession(session: URI): Promise; // ---- Terminal lifecycle ------------------------------------------------- diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index c70e73834a427b..d849bba39d1a72 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -4551ca9 +41634da diff --git a/src/vs/platform/agentHost/common/state/protocol/commands.ts b/src/vs/platform/agentHost/common/state/protocol/commands.ts index 9a26bee1ca48db..9e52e0fd1d74e6 100644 --- a/src/vs/platform/agentHost/common/state/protocol/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/commands.ts @@ -6,7 +6,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import type { URI, Snapshot, SessionConfigSchema, SessionSummary, ModelSelection, Turn, TerminalClaim, SessionActiveClient } from './state.js'; +import type { URI, Snapshot, SessionConfigSchema, SessionSummary, ModelSelection, Turn, TerminalClaim, SessionActiveClient, MessageAttachment } from './state.js'; import type { ActionEnvelope, StateAction } from './actions.js'; export type { ConfigPropertySchema, ConfigSchema, SessionConfigPropertySchema, SessionConfigSchema } from './state.js'; @@ -69,6 +69,13 @@ export interface InitializeResult { snapshots: Snapshot[]; /** Suggested default directory for remote filesystem browsing */ defaultDirectory?: URI; + /** + * Characters that, when typed in a {@link UserMessage} input, SHOULD cause + * the client to issue a `completions` request with + * {@link CompletionItemKind.UserMessage}. Typically includes characters like + * `'@'` or `'/'`. + */ + completionTriggerCharacters?: string[]; } // ─── reconnect ─────────────────────────────────────────────────────────────── @@ -927,3 +934,127 @@ export interface SessionConfigCompletionsResult { /** Matching value items */ items: SessionConfigValueItem[]; } + +// ─── completions ───────────────────────────────────────────────────────────── + +/** + * The kind of completion items being requested. + * + * @category Commands + */ +export const enum CompletionItemKind { + /** + * Completions for the text of a {@link UserMessage} the user is composing. + * Each returned item carries an attachment that gets associated with the + * message when accepted. + */ + UserMessage = 'userMessage', +} + +/** + * Requests completion items for a partially-typed input (e.g. a user message + * the user is currently composing). Used to power `@`-mention pickers, + * file/symbol references, and similar inline-completion experiences. + * + * Servers SHOULD treat this command as best-effort and return promptly. The + * client SHOULD debounce calls to avoid flooding the server with requests on + * every keystroke. + * + * @category Commands + * @method completions + * @direction Client → Server + * @messageType Request + * @version 1 + * @example + * ```jsonc + * // User has typed "look at @foo" and the cursor is just after "@foo". + * // Client → Server + * { "jsonrpc": "2.0", "id": 12, "method": "completions", + * "params": { "kind": "userMessage", "session": "copilot:/", + * "text": "look at @foo", "offset": 12 } } + * + * // Server → Client + * { "jsonrpc": "2.0", "id": 12, "result": { + * "items": [ + * { + * "insertText": "@foo.ts", + * "rangeStart": 8, + * "rangeEnd": 12, + * "attachment": { + * "type": "resource", + * "label": "foo.ts", + * "displayKind": "document", + * "uri": "file:///workspace/foo.ts" + * } + * } + * ] + * }} + * ``` + */ +export interface CompletionsParams { + /** What kind of completion is being requested. */ + kind: CompletionItemKind; + /** The session URI the completion is being requested for. */ + session: URI; + /** + * The complete text of the input being completed (e.g. the full user + * message text typed so far). + */ + text: string; + /** + * The character offset within `text` at which the completion is requested, + * measured in UTF-16 code units. MUST satisfy `0 <= offset <= text.length`. + */ + offset: number; +} + +/** + * A single completion item returned by the `completions` command. + * + * When the user accepts an item, the client SHOULD: + * 1. Replace the range `[rangeStart, rangeEnd)` in the input with `insertText` + * (or insert `insertText` at the cursor when the range is omitted). + * 2. Associate the item's `attachment` with the resulting {@link UserMessage}. + * + * @category Commands + */ +export interface CompletionItem { + /** + * The text inserted into the input when this item is accepted. + */ + insertText: string; + + /** + * If defined, the start of the range in the input's `text` that is replaced + * by `insertText`. The range is the half-open interval + * `[rangeStart, rangeEnd)` of character offsets, measured in UTF-16 code + * units. + * + * When omitted, the client SHOULD insert `insertText` at the cursor. + * + * Note: this range refers to positions in the *current* input. The + * attachment's own `rangeStart`/`rangeEnd` (when present) refer to + * positions in the final {@link UserMessage.text} after the item is + * accepted. + */ + rangeStart?: number; + + /** + * The end of the range in the input's `text` that is replaced by + * `insertText`. See {@link rangeStart}. + */ + rangeEnd?: number; + + /** + * The attachment associated with this completion item. + */ + attachment: MessageAttachment; +} + +/** + * Result of the `completions` command. + */ +export interface CompletionsResult { + /** The completion items, in the order the server suggests displaying them. */ + items: CompletionItem[]; +} diff --git a/src/vs/platform/agentHost/common/state/protocol/messages.ts b/src/vs/platform/agentHost/common/state/protocol/messages.ts index 4b1dda744433fb..6fe656b48d3a30 100644 --- a/src/vs/platform/agentHost/common/state/protocol/messages.ts +++ b/src/vs/platform/agentHost/common/state/protocol/messages.ts @@ -6,7 +6,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import type { InitializeParams, InitializeResult, ReconnectParams, ReconnectResult, SubscribeParams, SubscribeResult, CreateSessionParams, DisposeSessionParams, CreateTerminalParams, DisposeTerminalParams, ListSessionsParams, ListSessionsResult, ResourceReadParams, ResourceReadResult, ResourceWriteParams, ResourceWriteResult, ResourceListParams, ResourceListResult, ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceMoveParams, ResourceMoveResult, ResourceRequestParams, ResourceRequestResult, FetchTurnsParams, FetchTurnsResult, UnsubscribeParams, DispatchActionParams, AuthenticateParams, AuthenticateResult, ResolveSessionConfigParams, ResolveSessionConfigResult, SessionConfigCompletionsParams, SessionConfigCompletionsResult } from './commands.js'; +import type { InitializeParams, InitializeResult, ReconnectParams, ReconnectResult, SubscribeParams, SubscribeResult, CreateSessionParams, DisposeSessionParams, CreateTerminalParams, DisposeTerminalParams, ListSessionsParams, ListSessionsResult, ResourceReadParams, ResourceReadResult, ResourceWriteParams, ResourceWriteResult, ResourceListParams, ResourceListResult, ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceMoveParams, ResourceMoveResult, ResourceRequestParams, ResourceRequestResult, FetchTurnsParams, FetchTurnsResult, UnsubscribeParams, DispatchActionParams, AuthenticateParams, AuthenticateResult, ResolveSessionConfigParams, ResolveSessionConfigResult, SessionConfigCompletionsParams, SessionConfigCompletionsResult, CompletionsParams, CompletionsResult } from './commands.js'; import type { ActionEnvelope } from './actions.js'; import type { ProtocolNotification } from './notifications.js'; @@ -92,6 +92,7 @@ export interface CommandMap { 'authenticate': { params: AuthenticateParams; result: AuthenticateResult }; 'resolveSessionConfig': { params: ResolveSessionConfigParams; result: ResolveSessionConfigResult }; 'sessionConfigCompletions': { params: SessionConfigCompletionsParams; result: SessionConfigCompletionsResult }; + 'completions': { params: CompletionsParams; result: CompletionsResult }; } /** diff --git a/src/vs/platform/agentHost/electron-browser/agentHostService.ts b/src/vs/platform/agentHost/electron-browser/agentHostService.ts index d426f2b2c0d2c2..ceaced587b6a34 100644 --- a/src/vs/platform/agentHost/electron-browser/agentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/agentHostService.ts @@ -16,7 +16,7 @@ import { IConfigurationService } from '../../configuration/common/configuration. import { ILogService } from '../../log/common/log.js'; import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostService, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IAgentHostSocketInfo, IConnectionTrackerService } from '../common/agentService.js'; import { AgentSubscriptionManager, type IAgentSubscription } from '../common/state/agentSubscription.js'; -import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; +import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; import { StateComponents, ROOT_STATE_URI, type RootState } from '../common/state/sessionState.js'; @@ -152,6 +152,9 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { return this._proxy.sessionConfigCompletions(params); } + completions(params: CompletionsParams): Promise { + return this._proxy.completions(params); + } disposeSession(session: URI): Promise { return this._proxy.disposeSession(session); } diff --git a/src/vs/platform/agentHost/node/agentHostCompletions.ts b/src/vs/platform/agentHost/node/agentHostCompletions.ts new file mode 100644 index 00000000000000..8345e54e02fed5 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostCompletions.ts @@ -0,0 +1,94 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { ILogService } from '../../log/common/log.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import type { CompletionItem, CompletionItemKind, CompletionsParams, CompletionsResult } from '../common/state/protocol/commands.js'; + +export const IAgentHostCompletions = createDecorator('agentHostCompletions'); + +/** + * Pluggable provider that contributes {@link CompletionItem}s for one or + * more {@link CompletionItemKind}s. + * + * Providers are registered via {@link IAgentHostCompletions.registerProvider} + * and may be agent-specific (e.g. registered alongside an `IAgent`) or + * generic (e.g. the built-in workspace file completion provider). + */ +export interface IAgentHostCompletionItemProvider { + /** Completion kinds this provider handles. Providers are skipped for any other kind. */ + readonly kinds: ReadonlySet; + + /** + * Compute completion items for the given input. + * + * Implementations SHOULD respect `token` and return promptly. + * Throwing or rejecting fails this provider only; other providers' + * results are still returned by {@link IAgentHostCompletions.completions}. + */ + provideCompletionItems(params: CompletionsParams, token: CancellationToken): Promise; +} + +/** + * Server-side completions service. Owns a set of pluggable providers and + * fans out a single `completions` request to every provider whose + * {@link IAgentHostCompletionItemProvider.kinds} includes the requested kind. + * + * Provider results are concatenated in registration order; a single failing + * provider does not prevent other providers' results from being returned. + */ +export interface IAgentHostCompletions { + readonly _serviceBrand: undefined; + + /** + * Register a completion provider. The returned {@link IDisposable} unregisters + * the provider when disposed. + */ + registerProvider(provider: IAgentHostCompletionItemProvider): IDisposable; + + /** + * Compute completion items by fanning out to all matching providers. + */ + completions(params: CompletionsParams, token?: CancellationToken): Promise; +} + +export class AgentHostCompletions extends Disposable implements IAgentHostCompletions { + declare readonly _serviceBrand: undefined; + + private readonly _providers = new Set(); + + constructor( + @ILogService private readonly _logService: ILogService, + ) { + super(); + } + + registerProvider(provider: IAgentHostCompletionItemProvider): IDisposable { + this._providers.add(provider); + return toDisposable(() => this._providers.delete(provider)); + } + + async completions(params: CompletionsParams, token: CancellationToken = CancellationToken.None): Promise { + const matching = [...this._providers].filter(p => p.kinds.has(params.kind)); + if (matching.length === 0) { + return { items: [] }; + } + const settled = await Promise.allSettled( + matching.map(p => p.provideCompletionItems(params, token)), + ); + const items: CompletionItem[] = []; + for (let i = 0; i < settled.length; i++) { + const result = settled[i]; + if (result.status === 'fulfilled') { + items.push(...result.value); + } else { + this._logService.error(result.reason, `[AgentHostCompletions] Provider failed for kind=${params.kind}`); + } + } + return { items }; + } +} diff --git a/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts b/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts new file mode 100644 index 00000000000000..9dddf1ebd5019a --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { URI } from '../../../base/common/uri.js'; +import { CompletionItem, CompletionItemKind, CompletionsParams } from '../common/state/protocol/commands.js'; +import { IAgentHostCompletionItemProvider } from './agentHostCompletions.js'; +import { AgentHostStateManager } from './agentHostStateManager.js'; + +/** + * Generic completion provider that contributes workspace file references + * for a {@link CompletionItemKind.UserMessage} input — typically used for + * `@`-mentions in the user message composer. + * + * NOTE: This is currently a stub. The intended behaviour is: + * 1. Use {@link CompletionsParams.text} and {@link CompletionsParams.offset} + * to extract the `@`-prefixed token the user is typing. + * 2. List files under the session's workspace folder (resolved via the + * state manager) that match the token, respecting `.gitignore` and + * reasonable result limits. + * 3. Build {@link CompletionItem}s carrying a + * {@link MessageAttachmentKind.Resource} attachment pointing at the + * matched file URI. + */ +export class AgentHostFileCompletionProvider implements IAgentHostCompletionItemProvider { + + readonly kinds: ReadonlySet = new Set([CompletionItemKind.UserMessage]); + + constructor( + private readonly _stateManager: AgentHostStateManager, + ) { } + + async provideCompletionItems(params: CompletionsParams, _token: CancellationToken): Promise { + const workingDirectoryStr = this._stateManager.getSessionState(params.session)?.summary.workingDirectory; + if (!workingDirectoryStr) { + return []; + } + const workingDirectory = URI.parse(workingDirectoryStr); + // TODO: extract the `@`-token at params.offset, list files under + // `workingDirectory`, build CompletionItems with + // MessageAttachmentKind.Resource attachments. + void workingDirectory; + return []; + } +} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 0651701122d934..034e851e507fe8 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -21,7 +21,7 @@ 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 } from '../common/sessionDataService.js'; import { ActionType, ActionEnvelope, INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; -import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.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'; import { ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType, buildSubagentSessionUriPrefix, parseSubagentSessionUri, readSessionGitState, withSessionGitState, type SessionConfigState, type ISessionFileDiff, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { IProductService } from '../../product/common/productService.js'; @@ -32,6 +32,8 @@ import { ISessionDbUriFields, parseSessionDbUri } from './copilot/fileEditTracke import { IGitBlobUriFields, parseGitBlobUri } from './gitDiffContent.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { IAgentHostGitService } from './agentHostGitService.js'; +import { AgentHostCompletions, IAgentHostCompletions } from './agentHostCompletions.js'; +import { AgentHostFileCompletionProvider } from './agentHostFileCompletionProvider.js'; /** * Grace period before an empty, unsubscribed session is garbage-collected @@ -81,6 +83,8 @@ export class AgentService extends Disposable implements IAgentService { /** Manages PTY-backed terminals for the agent host protocol. */ private readonly _terminalManager: AgentHostTerminalManager; private readonly _configurationService: IAgentConfigurationService; + /** Pluggable completion item providers (e.g. workspace file completions, agent-specific @-mentions). */ + private readonly _completions: IAgentHostCompletions; /** * Authoritative server-side per-resource subscription refcount, keyed by @@ -131,6 +135,12 @@ export class AgentService extends Disposable implements IAgentService { ); const instantiationService = this._register(new InstantiationService(services, /*strict*/ true)); + this._completions = this._register(instantiationService.createInstance(AgentHostCompletions)); + // Built-in generic provider: completes files in the session's workspace folder. + this._register(this._completions.registerProvider( + new AgentHostFileCompletionProvider(this._stateManager), + )); + this._sideEffects = this._register(instantiationService.createInstance(AgentSideEffects, this._stateManager, { getAgent: session => this._findProviderForSession(session), sessionDataService: this._sessionDataService, @@ -507,6 +517,10 @@ export class AgentService extends Disposable implements IAgentService { return provider.sessionConfigCompletions(params); } + async completions(params: CompletionsParams): Promise { + return this._completions.completions(params); + } + async disposeSession(session: URI): Promise { this._logService.trace(`[AgentService] disposeSession: ${session.toString()}`); const provider = this._findProviderForSession(session); diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index cdfaf11005778f..d98d30796c783b 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -584,6 +584,9 @@ export class ProtocolServerHandler extends Disposable { query: params.query, }); }, + completions: async (_client, params) => { + return this._agentService.completions(params); + }, fetchTurns: async (_client, params) => { const state = this._stateManager.getSessionState(params.session); if (!state) { diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 7c1ccc4a0a2ee6..4dd37d35ba1bc0 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -11,7 +11,7 @@ import { runWithFakedTimers } from '../../../../base/test/common/timeTravelSched import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentService, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agentService.js'; -import { ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; +import { CompletionsParams, CompletionsResult, ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ActionType, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, AHP_UNSUPPORTED_PROTOCOL_VERSION, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; @@ -110,6 +110,7 @@ class MockAgentService implements IAgentService { async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise { return { schema: { type: 'object', properties: {} }, values: {} }; } async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { return { items: [] }; } + async completions(_params: CompletionsParams): Promise { return { items: [] }; } async disposeSession(_session: URI): Promise { } async listSessions(): Promise { return this.listedSessions; } async subscribe(resource: URI, _clientId: string): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts index 0d35d1bba75b7f..5e88b5b0ee5e56 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts @@ -11,7 +11,7 @@ import { IAgentConnection, IAgentCreateSessionConfig, IAgentResolveSessionConfig import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { StateComponents, type ComponentToState, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import type { ActionEnvelope, IRootConfigChangedAction, SessionAction, TerminalAction, INotification } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; +import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { Extensions, IOutputChannel, IOutputChannelRegistry, IOutputService } from '../../../../../services/output/common/output.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -192,6 +192,10 @@ export class LoggingAgentConnection extends Disposable implements IAgentConnecti return this._logCall('sessionConfigCompletions', params, () => this._inner.sessionConfigCompletions(params)); } + async completions(params: CompletionsParams): Promise { + return this._logCall('completions', params, () => this._inner.completions(params)); + } + async disposeSession(session: URI): Promise { return this._logCall('disposeSession', session, () => this._inner.disposeSession(session)); } diff --git a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts index 6b3c8ed9c57690..4e84a849b0de8c 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts @@ -11,7 +11,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { IAgentConnection, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../../../../../platform/agentHost/common/agentService.js'; import { ActionType, StateAction } from '../../../../../platform/agentHost/common/state/protocol/actions.js'; import { RootState, TerminalClaimKind, type TerminalState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; -import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; +import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import type { ActionEnvelope, IRootConfigChangedAction, SessionAction, TerminalAction, INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult } from '../../../../../platform/agentHost/common/state/sessionProtocol.js'; @@ -72,6 +72,7 @@ class MockAgentConnection implements IAgentConnection { async createSession(_config?: IAgentCreateSessionConfig): Promise { return URI.parse('copilot:///test'); } async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise { return { schema: { type: 'object', properties: {} }, values: {} }; } async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { return { items: [] }; } + async completions(_params: CompletionsParams): Promise { return { items: [] }; } async disposeSession(_session: URI): Promise { } async shutdown(): Promise { } async resourceList(_uri: URI): Promise { return { entries: [] }; } From 94aab29775a537245b4f7868f4901286261cd6e5 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 5 May 2026 16:33:36 -0700 Subject: [PATCH 08/26] Fix custom diff editor restore and custom name Make sure that custom diff editors show custom names such as the `(Indexed)` title that get provides Also makes sure custom diff editors are restored on reload by creating serializers for them --- .../browser/customEditor.contribution.ts | 13 ++- .../customEditor/browser/customEditorInput.ts | 15 +-- .../browser/customEditorInputFactory.ts | 104 ++++++++++++++++++ .../customEditor/browser/customEditors.ts | 12 +- 4 files changed, 126 insertions(+), 18 deletions(-) diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditor.contribution.ts b/src/vs/workbench/contrib/customEditor/browser/customEditor.contribution.ts index ab9cf7ccf961a4..a02a6f3aef69bb 100644 --- a/src/vs/workbench/contrib/customEditor/browser/customEditor.contribution.ts +++ b/src/vs/workbench/contrib/customEditor/browser/customEditor.contribution.ts @@ -9,10 +9,11 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/editor.js'; import { WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor.js'; -import { ComplexCustomWorkingCopyEditorHandler, CustomEditorInputSerializer } from './customEditorInputFactory.js'; +import { ComplexCustomWorkingCopyEditorHandler, CustomEditorDiffInputSerializer, CustomEditorInputSerializer, CustomEditorSideBySideDiffInputSerializer } from './customEditorInputFactory.js'; import { ICustomEditorService } from '../common/customEditor.js'; import { WebviewEditor } from '../../webviewPanel/browser/webviewEditor.js'; import { CustomEditorInput } from './customEditorInput.js'; +import { CustomEditorDiffInput, CustomEditorSideBySideDiffInput } from './customEditorDiffInput.js'; import { CustomEditorService } from './customEditors.js'; registerSingleton(ICustomEditorService, CustomEditorService, InstantiationType.Delayed); @@ -24,10 +25,18 @@ Registry.as(EditorExtensions.EditorPane) WebviewEditor.ID, 'Webview Editor', ), [ - new SyncDescriptor(CustomEditorInput) + new SyncDescriptor(CustomEditorInput), + new SyncDescriptor(CustomEditorDiffInput), + new SyncDescriptor(CustomEditorSideBySideDiffInput), ]); Registry.as(EditorExtensions.EditorFactory) .registerEditorSerializer(CustomEditorInputSerializer.ID, CustomEditorInputSerializer); +Registry.as(EditorExtensions.EditorFactory) + .registerEditorSerializer(CustomEditorDiffInputSerializer.ID, CustomEditorDiffInputSerializer); + +Registry.as(EditorExtensions.EditorFactory) + .registerEditorSerializer(CustomEditorSideBySideDiffInputSerializer.ID, CustomEditorSideBySideDiffInputSerializer); + registerWorkbenchContribution2(ComplexCustomWorkingCopyEditorHandler.ID, ComplexCustomWorkingCopyEditorHandler, WorkbenchPhase.BlockStartup); diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts b/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts index cef8490460b341..7072dfe39e2343 100644 --- a/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts +++ b/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts @@ -39,6 +39,7 @@ interface CustomEditorInputInitInfo { readonly resource: URI; readonly viewType: string; readonly webviewTitle: string | undefined; + readonly preferredName: string | undefined; readonly iconPath: WebviewIconPath | undefined; } @@ -109,7 +110,7 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput { @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @ICustomEditorLabelService private readonly customEditorLabelService: ICustomEditorLabelService, ) { - super({ providedId: init.viewType, viewType: init.viewType, name: '', iconPath: init.iconPath }, webview, themeService, webviewWorkbenchService); + super({ providedId: init.viewType, viewType: init.viewType, name: init.preferredName ?? '', iconPath: init.iconPath }, webview, themeService, webviewWorkbenchService); this._editorResource = init.resource; this.oldResource = options.oldResource; this._defaultDirtyState = options.startsDirty; @@ -166,14 +167,8 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput { capabilities |= EditorInputCapabilities.Singleton; } - if (this._modelRef) { - if (this._modelRef.object.isReadonly()) { - capabilities |= EditorInputCapabilities.Readonly; - } - } else { - if (this.filesConfigurationService.isReadonly(this.resource)) { - capabilities |= EditorInputCapabilities.Readonly; - } + if (this.isReadonly()) { + capabilities |= EditorInputCapabilities.Readonly; } if (this.resource.scheme === Schemas.untitled) { @@ -269,7 +264,7 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput { public override copy(): EditorInput { return CustomEditorInput.create(this.instantiationService, - { resource: this.resource, viewType: this.viewType, webviewTitle: this.getWebviewTitle(), iconPath: this.iconPath, }, + { resource: this.resource, viewType: this.viewType, webviewTitle: this.getWebviewTitle(), preferredName: undefined, iconPath: this.iconPath, }, this.group, this.webview.options); } diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditorInputFactory.ts b/src/vs/workbench/contrib/customEditor/browser/customEditorInputFactory.ts index cb9da93c4714f8..3a5453e5e6f675 100644 --- a/src/vs/workbench/contrib/customEditor/browser/customEditorInputFactory.ts +++ b/src/vs/workbench/contrib/customEditor/browser/customEditorInputFactory.ts @@ -9,8 +9,10 @@ import { isEqual } from '../../../../base/common/resources.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { IEditorSerializer } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { CustomEditorInput } from './customEditorInput.js'; +import { CustomEditorDiffInput, CustomEditorSideBySideDiffInput, CustomEditorSideBySideDiffSide } from './customEditorDiffInput.js'; import { ICustomEditorService } from '../common/customEditor.js'; import { NotebookEditorInput } from '../../notebook/common/notebookEditorInput.js'; import { IWebviewService, WebviewContentOptions, WebviewContentPurpose, WebviewExtensionDescription, WebviewOptions } from '../../webview/browser/webview.js'; @@ -101,6 +103,7 @@ export class CustomEditorInputSerializer extends WebviewEditorInputSerializer { resource: data.editorResource, viewType: data.viewType, webviewTitle: data.title, + preferredName: undefined, iconPath: data.iconPath, }, webview, { startsDirty: data.dirty, backupId: data.backupId }); if (typeof data.group === 'number') { @@ -110,6 +113,106 @@ export class CustomEditorInputSerializer extends WebviewEditorInputSerializer { } } +interface SerializedCustomEditorDiff { + readonly originalResource: UriComponents; + readonly modifiedResource: UriComponents; + readonly viewType: string; + readonly label: string | undefined; + readonly description: string | undefined; + readonly dirty: boolean; +} + +export class CustomEditorDiffInputSerializer implements IEditorSerializer { + + public static readonly ID = CustomEditorDiffInput.typeId; + + public constructor( + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { } + + canSerialize(input: EditorInput): boolean { + return input instanceof CustomEditorDiffInput; + } + + serialize(input: CustomEditorDiffInput): string | undefined { + const data: SerializedCustomEditorDiff = { + originalResource: input.originalResource.toJSON(), + modifiedResource: input.modifiedResource.toJSON(), + viewType: input.viewType, + label: input.getName(), + description: input.getDescription(), + dirty: input.isDirty(), + }; + try { + return JSON.stringify(data); + } catch { + return undefined; + } + } + + deserialize(_instantiationService: IInstantiationService, serializedEditorInput: string): EditorInput { + const data: SerializedCustomEditorDiff = JSON.parse(serializedEditorInput); + return CustomEditorDiffInput.create(this._instantiationService, { + originalResource: URI.revive(data.originalResource), + modifiedResource: URI.revive(data.modifiedResource), + viewType: data.viewType, + label: data.label, + description: data.description, + iconPath: undefined, + }, undefined); + } +} + +interface SerializedCustomEditorSideBySideDiff extends SerializedCustomEditorDiff { + readonly diffId: string; + readonly side: CustomEditorSideBySideDiffSide; +} + +export class CustomEditorSideBySideDiffInputSerializer implements IEditorSerializer { + + public static readonly ID = CustomEditorSideBySideDiffInput.typeId; + + public constructor( + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { } + + canSerialize(input: EditorInput): boolean { + return input instanceof CustomEditorSideBySideDiffInput; + } + + serialize(input: CustomEditorSideBySideDiffInput): string | undefined { + const data: SerializedCustomEditorSideBySideDiff = { + originalResource: input.originalResource.toJSON(), + modifiedResource: input.modifiedResource.toJSON(), + viewType: input.viewType, + label: input.getName(), + description: input.getDescription(), + dirty: input.isDirty(), + diffId: input.diffId, + side: input.side, + }; + try { + return JSON.stringify(data); + } catch { + return undefined; + } + } + + deserialize(_instantiationService: IInstantiationService, serializedEditorInput: string): EditorInput { + const data: SerializedCustomEditorSideBySideDiff = JSON.parse(serializedEditorInput); + return CustomEditorSideBySideDiffInput.create(this._instantiationService, { + originalResource: URI.revive(data.originalResource), + modifiedResource: URI.revive(data.modifiedResource), + viewType: data.viewType, + label: data.label, + description: data.description, + iconPath: undefined, + diffId: data.diffId, + side: data.side, + }, undefined); + } +} + function reviveWebview(webviewService: IWebviewService, data: { origin: string | undefined; viewType: string; state: any; webviewOptions: WebviewOptions; contentOptions: WebviewContentOptions; extension?: WebviewExtensionDescription; title: string | undefined }) { const webview = webviewService.createWebviewOverlay({ providedViewType: data.viewType, @@ -202,6 +305,7 @@ export class ComplexCustomWorkingCopyEditorHandler extends Disposable implements resource: URI.revive(backupData.editorResource), viewType: backupData.viewType, webviewTitle: backupData.customTitle, + preferredName: undefined, iconPath: reviveWebviewIconPath(backupData.iconPath) }, webview, { backupId: backupData.backupId }); editor.updateGroup(0); diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditors.ts b/src/vs/workbench/contrib/customEditor/browser/customEditors.ts index d9106e60e11f44..905e06f941ee48 100644 --- a/src/vs/workbench/contrib/customEditor/browser/customEditors.ts +++ b/src/vs/workbench/contrib/customEditor/browser/customEditors.ts @@ -182,11 +182,11 @@ export class CustomEditorService extends Disposable implements ICustomEditorServ singlePerResource: () => !(this.getCustomEditorCapabilities(contributedEditor.id)?.supportsMultipleEditorsPerDocument ?? false) }, { - createEditorInput: ({ resource }, group) => { - return { editor: CustomEditorInput.create(this.instantiationService, { resource, viewType: contributedEditor.id, webviewTitle: undefined, iconPath: undefined }, group.id) }; + createEditorInput: ({ resource, label }, group) => { + return { editor: CustomEditorInput.create(this.instantiationService, { resource, viewType: contributedEditor.id, webviewTitle: undefined, preferredName: label, iconPath: undefined }, group.id) }; }, createUntitledEditorInput: ({ resource }, group) => { - return { editor: CustomEditorInput.create(this.instantiationService, { resource: resource ?? URI.from({ scheme: Schemas.untitled, authority: `Untitled-${this._untitledCounter++}` }), viewType: contributedEditor.id, webviewTitle: undefined, iconPath: undefined }, group.id) }; + return { editor: CustomEditorInput.create(this.instantiationService, { resource: resource ?? URI.from({ scheme: Schemas.untitled, authority: `Untitled-${this._untitledCounter++}` }), viewType: contributedEditor.id, webviewTitle: undefined, preferredName: undefined, iconPath: undefined }, group.id) }; }, createDiffEditorInput: async (diffEditorInput, group) => { await this.extensionService.activateByEvent(`onCustomEditor:${contributedEditor.id}`); @@ -243,8 +243,8 @@ export class CustomEditorService extends Disposable implements ICustomEditorServ return this.instantiationService.createInstance(DiffEditorInput, editor.label, editor.description, originalOverride, modifiedOverride, true); } - const modifiedOverride = CustomEditorInput.create(this.instantiationService, { resource: modifiedResource, viewType: contributedEditor.id, webviewTitle: undefined, iconPath: undefined }, group.id, { customClasses: 'modified' }); - const originalOverride = CustomEditorInput.create(this.instantiationService, { resource: originalResource, viewType: contributedEditor.id, webviewTitle: undefined, iconPath: undefined }, group.id, { customClasses: 'original' }); + const modifiedOverride = CustomEditorInput.create(this.instantiationService, { resource: modifiedResource, viewType: contributedEditor.id, webviewTitle: undefined, preferredName: undefined, iconPath: undefined }, group.id, { customClasses: 'modified' }); + const originalOverride = CustomEditorInput.create(this.instantiationService, { resource: originalResource, viewType: contributedEditor.id, webviewTitle: undefined, preferredName: undefined, iconPath: undefined }, group.id, { customClasses: 'original' }); return this.instantiationService.createInstance(DiffEditorInput, editor.label, editor.description, originalOverride, modifiedOverride, true); } @@ -437,7 +437,7 @@ export class CustomEditorService extends Disposable implements ICustomEditorServ let replacement: EditorInput | IResourceEditorInput; if (possibleEditors.defaultEditor) { const viewType = possibleEditors.defaultEditor.id; - replacement = CustomEditorInput.create(this.instantiationService, { resource: newResource, viewType, webviewTitle: undefined, iconPath: undefined }, group); + replacement = CustomEditorInput.create(this.instantiationService, { resource: newResource, viewType, webviewTitle: undefined, preferredName: undefined, iconPath: undefined }, group); } else { replacement = { resource: newResource, options: { override: DEFAULT_EDITOR_ASSOCIATION.id } }; } From 7efafecbbe6b14ff097405cfbb0783cec9929bdb Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 5 May 2026 16:41:34 -0700 Subject: [PATCH 09/26] Disable doubleClickToSwitchToEditor and markEditorSelection by default Fixes #314587 --- extensions/markdown-language-features/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 86161042221dc3..1745b18cbfcfa5 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -741,7 +741,7 @@ }, "markdown.preview.markEditorSelection": { "type": "boolean", - "default": true, + "default": false, "description": "%markdown.preview.markEditorSelection.desc%", "scope": "resource" }, @@ -753,7 +753,7 @@ }, "markdown.preview.doubleClickToSwitchToEditor": { "type": "boolean", - "default": true, + "default": false, "description": "%markdown.preview.doubleClickToSwitchToEditor.desc%", "scope": "resource" }, From c7bc16d4237b4884a8f0a7898defc132ac246bdb Mon Sep 17 00:00:00 2001 From: Paul Date: Tue, 5 May 2026 17:41:49 -0700 Subject: [PATCH 10/26] Fix UBB detection (#314584) --- src/vs/base/common/defaultAccount.ts | 1 + .../common/chatEntitlementService.test.ts | 187 ++++++++++++++++++ .../chat/common/chatEntitlementService.ts | 112 ++++++----- 3 files changed, 246 insertions(+), 54 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts diff --git a/src/vs/base/common/defaultAccount.ts b/src/vs/base/common/defaultAccount.ts index 83b98183bcf20c..f062af631e5ace 100644 --- a/src/vs/base/common/defaultAccount.ts +++ b/src/vs/base/common/defaultAccount.ts @@ -35,6 +35,7 @@ export interface IEntitlementsData extends ILegacyQuotaSnapshotData { readonly limited_user_reset_date?: string; // for Copilot Free readonly quota_reset_date?: string; // for all other Copilot SKUs readonly quota_reset_date_utc?: string; // for all other Copilot SKUs (includes time) + readonly token_based_billing?: boolean; readonly quota_snapshots?: { chat?: IQuotaSnapshotData; completions?: IQuotaSnapshotData; diff --git a/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts new file mode 100644 index 00000000000000..5141cbc5435755 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts @@ -0,0 +1,187 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { IEntitlementsData } from '../../../../../base/common/defaultAccount.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { parseQuotas } from '../../../../services/chat/common/chatEntitlementService.js'; + +suite('parseQuotas', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function makeEntitlementsData(overrides: Partial): IEntitlementsData { + return { + access_type_sku: 'plus_monthly_subscriber_quota', + chat_enabled: true, + assigned_date: '2026-04-17T12:53:45-07:00', + can_signup_for_limited: false, + copilot_plan: 'individual_pro', + organization_login_list: [], + analytics_tracking_id: 'test', + ...overrides, + }; + } + + test('reads token_based_billing from top-level, not from quota snapshot', () => { + const data = makeEntitlementsData({ + token_based_billing: true, + quota_snapshots: { + premium_interactions: { + overage_count: 0, + overage_permitted: true, + percent_remaining: 97.4, + unlimited: false, + // no token_based_billing here — paid users don't have it per-snapshot + }, + }, + }); + + const quotas = parseQuotas(data); + assert.strictEqual(quotas.premiumChat?.usageBasedBilling, true); + }); + + test('usageBasedBilling is undefined when top-level token_based_billing is absent', () => { + const data = makeEntitlementsData({ + quota_snapshots: { + premium_interactions: { + overage_count: 0, + overage_permitted: false, + percent_remaining: 80, + unlimited: false, + }, + }, + }); + + const quotas = parseQuotas(data); + assert.strictEqual(quotas.premiumChat?.usageBasedBilling, undefined); + }); + + test('all quota types receive top-level token_based_billing', () => { + const data = makeEntitlementsData({ + token_based_billing: true, + quota_snapshots: { + chat: { + overage_count: 0, + overage_permitted: false, + percent_remaining: 100, + unlimited: true, + }, + completions: { + overage_count: 0, + overage_permitted: false, + percent_remaining: 100, + unlimited: true, + }, + premium_interactions: { + overage_count: 0, + overage_permitted: true, + percent_remaining: 97.4, + unlimited: false, + }, + }, + }); + + const quotas = parseQuotas(data); + assert.strictEqual(quotas.chat?.usageBasedBilling, true); + assert.strictEqual(quotas.completions?.usageBasedBilling, true); + assert.strictEqual(quotas.premiumChat?.usageBasedBilling, true); + }); + + test('parses paid user response correctly (top-level token_based_billing only)', () => { + const data = makeEntitlementsData({ + quota_reset_date: '2026-06-01', + quota_reset_date_utc: '2026-06-01T00:00:00.000Z', + token_based_billing: true, + quota_snapshots: { + chat: { + overage_count: 0, + overage_permitted: false, + percent_remaining: 100, + unlimited: true, + entitlement: '0', + }, + completions: { + overage_count: 0, + overage_permitted: false, + percent_remaining: 100, + unlimited: true, + entitlement: '0', + }, + premium_interactions: { + overage_count: 0, + overage_permitted: true, + percent_remaining: 97.4, + unlimited: false, + entitlement: '3900', + }, + }, + }); + + const quotas = parseQuotas(data); + assert.deepStrictEqual(quotas, { + resetDate: '2026-06-01T00:00:00.000Z', + resetDateHasTime: true, + chat: { + percentRemaining: 100, + unlimited: true, + usageBasedBilling: true, + resetAt: undefined, + entitlement: 0, + }, + completions: { + percentRemaining: 100, + unlimited: true, + usageBasedBilling: true, + resetAt: undefined, + entitlement: 0, + }, + premiumChat: { + percentRemaining: 97.4, + unlimited: false, + usageBasedBilling: true, + resetAt: undefined, + entitlement: 3900, + }, + additionalUsageEnabled: true, + additionalUsageCount: 0, + }); + }); + + test('parses free user CFI response with per-snapshot token_based_billing', () => { + const data = makeEntitlementsData({ + access_type_sku: 'free_limited_copilot', + copilot_plan: 'free', + token_based_billing: true, + quota_snapshots: { + chat: { + overage_count: 0, + overage_permitted: false, + percent_remaining: 98.7, + unlimited: false, + }, + completions: { + overage_count: 0, + overage_permitted: false, + percent_remaining: 100, + unlimited: false, + }, + premium_interactions: { + overage_count: 0, + overage_permitted: false, + percent_remaining: 0, + unlimited: false, + }, + }, + }); + + const quotas = parseQuotas(data); + assert.strictEqual(quotas.chat?.usageBasedBilling, true); + assert.strictEqual(quotas.completions?.usageBasedBilling, true); + assert.strictEqual(quotas.premiumChat?.usageBasedBilling, true); + assert.strictEqual(quotas.premiumChat?.percentRemaining, 0); + assert.strictEqual(quotas.additionalUsageEnabled, false); + }); +}); diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index 912d5bbf6d7760..07616875045d28 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -685,6 +685,63 @@ interface IQuotas { readonly additionalUsageCount?: number; } +export function parseQuotas(entitlementsData: IEntitlementsData): IQuotas { + const quotas: Mutable = { + resetDate: entitlementsData.quota_reset_date_utc ?? entitlementsData.quota_reset_date ?? entitlementsData.limited_user_reset_date, + resetDateHasTime: typeof entitlementsData.quota_reset_date_utc === 'string', + }; + + // Legacy Free SKU Quota + if (entitlementsData.monthly_quotas?.chat && typeof entitlementsData.limited_user_quotas?.chat === 'number') { + quotas.chat = { + percentRemaining: Math.min(100, Math.max(0, (entitlementsData.limited_user_quotas.chat / entitlementsData.monthly_quotas.chat) * 100)), + unlimited: false + }; + } + + if (entitlementsData.monthly_quotas?.completions && typeof entitlementsData.limited_user_quotas?.completions === 'number') { + quotas.completions = { + percentRemaining: Math.min(100, Math.max(0, (entitlementsData.limited_user_quotas.completions / entitlementsData.monthly_quotas.completions) * 100)), + unlimited: false + }; + } + + // New Quota Snapshot + if (entitlementsData.quota_snapshots) { + for (const quotaType of ['chat', 'completions', 'premium_interactions'] as const) { + const rawQuotaSnapshot = entitlementsData.quota_snapshots[quotaType]; + if (!rawQuotaSnapshot) { + continue; + } + const parsedEntitlement = rawQuotaSnapshot.entitlement !== undefined ? Number(rawQuotaSnapshot.entitlement) : undefined; + const quotaSnapshot: IQuotaSnapshot = { + percentRemaining: Math.min(100, Math.max(0, rawQuotaSnapshot.percent_remaining)), + unlimited: rawQuotaSnapshot.unlimited, + usageBasedBilling: entitlementsData.token_based_billing, + resetAt: rawQuotaSnapshot.quota_reset_at || undefined, + entitlement: parsedEntitlement !== undefined && Number.isSafeInteger(parsedEntitlement) && parsedEntitlement >= 0 ? parsedEntitlement : undefined, + }; + + switch (quotaType) { + case 'chat': + quotas.chat = quotaSnapshot; + break; + case 'completions': + quotas.completions = quotaSnapshot; + break; + case 'premium_interactions': + quotas.premiumChat = quotaSnapshot; + break; + } + } + + const overageSource = entitlementsData.quota_snapshots['premium_interactions']; + quotas.additionalUsageEnabled = overageSource?.overage_permitted ?? false; + quotas.additionalUsageCount = overageSource?.overage_count ?? 0; + } + return quotas; +} + export class ChatEntitlementRequests extends Disposable { private state: IEntitlements; @@ -821,60 +878,7 @@ export class ChatEntitlementRequests extends Disposable { } private toQuotas(entitlementsData: IEntitlementsData): IQuotas { - const quotas: Mutable = { - resetDate: entitlementsData.quota_reset_date_utc ?? entitlementsData.quota_reset_date ?? entitlementsData.limited_user_reset_date, - resetDateHasTime: typeof entitlementsData.quota_reset_date_utc === 'string', - }; - - // Legacy Free SKU Quota - if (entitlementsData.monthly_quotas?.chat && typeof entitlementsData.limited_user_quotas?.chat === 'number') { - quotas.chat = { - percentRemaining: Math.min(100, Math.max(0, (entitlementsData.limited_user_quotas.chat / entitlementsData.monthly_quotas.chat) * 100)), - unlimited: false - }; - } - - if (entitlementsData.monthly_quotas?.completions && typeof entitlementsData.limited_user_quotas?.completions === 'number') { - quotas.completions = { - percentRemaining: Math.min(100, Math.max(0, (entitlementsData.limited_user_quotas.completions / entitlementsData.monthly_quotas.completions) * 100)), - unlimited: false - }; - } - - // New Quota Snapshot - if (entitlementsData.quota_snapshots) { - for (const quotaType of ['chat', 'completions', 'premium_interactions'] as const) { - const rawQuotaSnapshot = entitlementsData.quota_snapshots[quotaType]; - if (!rawQuotaSnapshot) { - continue; - } - const parsedEntitlement = rawQuotaSnapshot.entitlement !== undefined ? Number(rawQuotaSnapshot.entitlement) : undefined; - const quotaSnapshot: IQuotaSnapshot = { - percentRemaining: Math.min(100, Math.max(0, rawQuotaSnapshot.percent_remaining)), - unlimited: rawQuotaSnapshot.unlimited, - usageBasedBilling: rawQuotaSnapshot.token_based_billing, - resetAt: rawQuotaSnapshot.quota_reset_at || undefined, - entitlement: parsedEntitlement !== undefined && Number.isSafeInteger(parsedEntitlement) && parsedEntitlement >= 0 ? parsedEntitlement : undefined, - }; - - switch (quotaType) { - case 'chat': - quotas.chat = quotaSnapshot; - break; - case 'completions': - quotas.completions = quotaSnapshot; - break; - case 'premium_interactions': - quotas.premiumChat = quotaSnapshot; - break; - } - } - - const overageSource = entitlementsData.quota_snapshots['premium_interactions']; - quotas.additionalUsageEnabled = overageSource?.overage_permitted ?? false; - quotas.additionalUsageCount = overageSource?.overage_count ?? 0; - } - return quotas; + return parseQuotas(entitlementsData); } private async request(url: string, type: 'GET', body: undefined, sessions: AuthenticationSession[], token: CancellationToken, callSite: string): Promise; From be340b1fa87aac1afcf1ab501ee5cb86473c67a7 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 6 May 2026 03:00:01 +0200 Subject: [PATCH 11/26] Introduce proposed `agentsWindow` configuration extension point (#314575) * Introduce proposed agentsWindow configuration extension point Add a new `agentsWindow` property to the configuration contribution point that allows extensions to declare per-setting default value overrides and read-only behavior for the Agents window. Shape: `agentsWindow: { default?: unknown; readOnly?: boolean }` - Gated behind `agentsWindowConfiguration` proposed API - SessionsDefaultConfiguration uses agentsWindow.default as the default value - ReadOnly settings are excluded from user configuration parsing and cannot be written via updateValue - Settings editor shows lock indicator for readOnly settings - `@override:agentsWindow` filter in settings search (agents window only) - Adopted all existing hardcoded session defaults to use the new schema Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * revert * 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> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- extensions/copilot/package.json | 60 +++++++++++--- extensions/git/package.json | 27 +++++-- .../config/editorConfigurationSchema.ts | 16 ++-- .../common/configurationRegistry.ts | 16 ++++ .../configuration/common/configurations.ts | 6 +- .../common/extensionsApiProposals.ts | 3 + .../common/update.config.contribution.ts | 3 +- .../browser/configuration.contribution.ts | 71 ----------------- .../browser/configurationService.ts | 50 ++++++++++-- .../test/browser/configurationService.test.ts | 78 +++++++++++++++++++ .../api/common/configurationExtensionPoint.ts | 20 +++++ .../browser/parts/editor/breadcrumbs.ts | 3 +- .../browser/workbench.contribution.ts | 14 +++- .../features/browserEditorChatFeatures.ts | 3 +- .../features/browserTabManagementFeatures.ts | 3 +- .../contrib/chat/browser/chat.contribution.ts | 8 +- .../browser/extensions.contribution.ts | 3 +- .../files/browser/files.contribution.ts | 3 +- .../contrib/inlineChat/common/inlineChat.ts | 3 +- .../preferences/browser/settingsEditor2.ts | 9 ++- .../settingsEditorSettingIndicators.ts | 12 +++ .../preferences/browser/settingsTree.ts | 14 ++-- .../preferences/browser/settingsTreeModels.ts | 37 +++++++-- .../contrib/preferences/common/preferences.ts | 1 + .../search/browser/search.contribution.ts | 3 +- .../tasks/browser/task.contribution.ts | 3 +- .../terminalChatAgentToolsConfiguration.ts | 3 +- .../terminalInitialHintConfiguration.ts | 3 +- .../browser/gettingStarted.contribution.ts | 3 +- .../electron-browser/desktop.contribution.ts | 4 +- ...de.proposed.agentsWindowConfiguration.d.ts | 8 ++ 31 files changed, 360 insertions(+), 130 deletions(-) create mode 100644 src/vscode-dts/vscode.proposed.agentsWindowConfiguration.d.ts diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 5ad346a6db4580..2b9610889308f0 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -89,6 +89,7 @@ "l10n": "./l10n", "enabledApiProposals": [ "agentSessionsWorkspace", + "agentsWindowConfiguration", "chatDebug", "chatHooks", "extensionsAny", @@ -3137,7 +3138,13 @@ "additionalProperties": { "type": "boolean" }, - "markdownDescription": "Enable or disable auto triggering of Copilot completions for specified [languages](https://code.visualstudio.com/docs/languages/identifiers). You can still trigger suggestions manually using `Alt + \\`" + "markdownDescription": "Enable or disable auto triggering of Copilot completions for specified [languages](https://code.visualstudio.com/docs/languages/identifiers). You can still trigger suggestions manually using `Alt + \\`", + "agentsWindow": { + "default": { + "markdown": true, + "plaintext": true + } + } }, "github.copilot.selectedCompletionModel": { "type": "string", @@ -3306,7 +3313,10 @@ "markdownDescription": "%github.copilot.config.githubMcpServer.enabled%", "tags": [ "experimental" - ] + ], + "agentsWindow": { + "default": true + } }, "github.copilot.chat.githubMcpServer.toolsets": { "type": "array", @@ -3652,7 +3662,10 @@ "experimental", "onExP" ], - "markdownDescription": "%github.copilot.chat.languageContext.typescript.enabled%" + "markdownDescription": "%github.copilot.chat.languageContext.typescript.enabled%", + "agentsWindow": { + "default": true + } }, "github.copilot.chat.languageContext.typescript.items": { "type": "string", @@ -4648,7 +4661,10 @@ "tags": [ "advanced", "experimental" - ] + ], + "agentsWindow": { + "default": true + } }, "github.copilot.chat.cli.branchSupport.enabled": { "type": "boolean", @@ -4656,7 +4672,10 @@ "markdownDescription": "%github.copilot.config.cli.branchSupport.enabled%", "tags": [ "advanced" - ] + ], + "agentsWindow": { + "default": true + } }, "github.copilot.chat.cli.showExternalSessions": { "type": "boolean", @@ -4664,7 +4683,10 @@ "markdownDescription": "%github.copilot.config.cli.showExternalSessions%", "tags": [ "advanced" - ] + ], + "agentsWindow": { + "default": false + } }, "github.copilot.chat.cli.planExitMode.enabled": { "type": "boolean", @@ -4704,7 +4726,10 @@ "markdownDescription": "%github.copilot.config.cli.lazyLoadSessionItem.enabled%", "tags": [ "advanced" - ] + ], + "agentsWindow": { + "default": false + } }, "github.copilot.chat.cli.aiGenerateBranchNames.enabled": { "type": "boolean", @@ -4728,7 +4753,10 @@ "markdownDescription": "%github.copilot.config.cli.isolationOption.enabled%", "tags": [ "advanced" - ] + ], + "agentsWindow": { + "default": true + } }, "github.copilot.chat.cli.autoCommit.enabled": { "type": "boolean", @@ -4737,7 +4765,10 @@ "tags": [ "advanced", "experimental" - ] + ], + "agentsWindow": { + "default": false + } }, "github.copilot.chat.cli.sessionController.enabled": { "type": "boolean", @@ -4745,7 +4776,11 @@ "markdownDescription": "%github.copilot.config.cli.sessionController.enabled%", "tags": [ "advanced" - ] + ], + "agentsWindow": { + "default": false, + "readOnly": true + } }, "github.copilot.chat.cli.thinkingEffort.enabled": { "type": "boolean", @@ -4777,7 +4812,10 @@ "markdownDescription": "%github.copilot.config.cli.remote.enabled%", "tags": [ "advanced" - ] + ], + "agentsWindow": { + "default": false + } }, "github.copilot.chat.searchSubagent.enabled": { "type": "boolean", diff --git a/extensions/git/package.json b/extensions/git/package.json index fe791389aaa067..0f8a4252c49120 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -11,6 +11,7 @@ "aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255", "enabledApiProposals": [ "agentSessionsWorkspace", + "agentsWindowConfiguration", "canonicalUriProvider", "contribEditSessions", "contribEditorContentMenu", @@ -3321,7 +3322,10 @@ "git.autorefresh": { "type": "boolean", "description": "%config.autorefresh%", - "default": true + "default": true, + "agentsWindow": { + "default": true + } }, "git.autofetch": { "type": [ @@ -3338,7 +3342,10 @@ "default": false, "tags": [ "usesOnlineServices" - ] + ], + "agentsWindow": { + "default": true + } }, "git.autofetchPeriod": { "type": "number", @@ -3397,7 +3404,10 @@ "type": "boolean", "description": "%config.branchRandomNameEnable%", "default": false, - "scope": "resource" + "scope": "resource", + "agentsWindow": { + "default": true + } }, "git.branchRandomName.dictionary": { "type": "array", @@ -3695,7 +3705,10 @@ "type": "boolean", "scope": "resource", "default": false, - "description": "%config.detectWorktrees%" + "description": "%config.detectWorktrees%", + "agentsWindow": { + "default": false + } }, "git.detectWorktreesLimit": { "type": "number", @@ -3771,7 +3784,11 @@ "type": "boolean", "description": "%config.showProgress%", "default": true, - "scope": "resource" + "scope": "resource", + "agentsWindow": { + "default": false, + "readOnly": true + } }, "git.rebaseWhenSync": { "type": "boolean", diff --git a/src/vs/editor/common/config/editorConfigurationSchema.ts b/src/vs/editor/common/config/editorConfigurationSchema.ts index 2a0641f8c969ab..255be2c4cbf70f 100644 --- a/src/vs/editor/common/config/editorConfigurationSchema.ts +++ b/src/vs/editor/common/config/editorConfigurationSchema.ts @@ -207,7 +207,8 @@ const editorConfiguration: IConfigurationNode = { 'diffEditor.renderSideBySide': { type: 'boolean', default: diffEditorDefaultOptions.renderSideBySide, - description: nls.localize('sideBySide', "Controls whether the diff editor shows the diff side by side or inline.") + description: nls.localize('sideBySide', "Controls whether the diff editor shows the diff side by side or inline."), + agentsWindow: { default: true }, }, 'diffEditor.renderSideBySideInlineBreakpoint': { type: 'number', @@ -217,17 +218,20 @@ const editorConfiguration: IConfigurationNode = { 'diffEditor.useInlineViewWhenSpaceIsLimited': { type: 'boolean', default: diffEditorDefaultOptions.useInlineViewWhenSpaceIsLimited, - description: nls.localize('useInlineViewWhenSpaceIsLimited', "If enabled and the editor width is too small, the inline view is used.") + description: nls.localize('useInlineViewWhenSpaceIsLimited', "If enabled and the editor width is too small, the inline view is used."), + agentsWindow: { default: true }, }, 'diffEditor.renderMarginRevertIcon': { type: 'boolean', default: diffEditorDefaultOptions.renderMarginRevertIcon, - description: nls.localize('renderMarginRevertIcon', "When enabled, the diff editor shows arrows in its glyph margin to revert changes.") + description: nls.localize('renderMarginRevertIcon', "When enabled, the diff editor shows arrows in its glyph margin to revert changes."), + agentsWindow: { default: false }, }, 'diffEditor.renderGutterMenu': { type: 'boolean', default: diffEditorDefaultOptions.renderGutterMenu, - description: nls.localize('renderGutterMenu', "When enabled, the diff editor shows a special gutter for revert and stage actions.") + description: nls.localize('renderGutterMenu', "When enabled, the diff editor shows a special gutter for revert and stage actions."), + agentsWindow: { default: false }, }, 'diffEditor.ignoreTrimWhitespace': { type: 'boolean', @@ -237,7 +241,8 @@ const editorConfiguration: IConfigurationNode = { 'diffEditor.renderIndicators': { type: 'boolean', default: diffEditorDefaultOptions.renderIndicators, - description: nls.localize('renderIndicators', "Controls whether the diff editor shows +/- indicators for added/removed changes.") + description: nls.localize('renderIndicators', "Controls whether the diff editor shows +/- indicators for added/removed changes."), + agentsWindow: { default: false }, }, 'diffEditor.codeLens': { type: 'boolean', @@ -267,6 +272,7 @@ const editorConfiguration: IConfigurationNode = { type: 'boolean', default: diffEditorDefaultOptions.hideUnchangedRegions.enabled, markdownDescription: nls.localize('hideUnchangedRegions.enabled', "Controls whether the diff editor shows unchanged regions."), + agentsWindow: { default: true }, }, 'diffEditor.hideUnchangedRegions.revealLineCount': { type: 'integer', diff --git a/src/vs/platform/configuration/common/configurationRegistry.ts b/src/vs/platform/configuration/common/configurationRegistry.ts index a212c47ef1bb2b..5385cfbcecce4b 100644 --- a/src/vs/platform/configuration/common/configurationRegistry.ts +++ b/src/vs/platform/configuration/common/configurationRegistry.ts @@ -240,6 +240,22 @@ export interface IConfigurationPropertySchema extends IJSONSchema { */ name?: string; }; + + /** + * When specified, provides configuration overrides for the Agents window. + */ + agentsWindow?: { + /** + * Override default value for this setting in the Agents window. + */ + default?: unknown; + + /** + * When `true`, this setting is read-only in the Agents window + * and cannot be changed by the user. + */ + readOnly?: boolean; + }; } export interface IExtensionInfo { diff --git a/src/vs/platform/configuration/common/configurations.ts b/src/vs/platform/configuration/common/configurations.ts index 887aadae5ce52c..276d919e76a9d1 100644 --- a/src/vs/platform/configuration/common/configurations.ts +++ b/src/vs/platform/configuration/common/configurations.ts @@ -67,13 +67,17 @@ export class DefaultConfiguration extends Disposable { if (defaultOverrideValue !== undefined) { this._configurationModel.setValue(key, defaultOverrideValue); } else if (propertySchema) { - this._configurationModel.setValue(key, deepClone(propertySchema.default)); + this._configurationModel.setValue(key, this.getDefaultValue(key, propertySchema)); } else { this._configurationModel.removeValue(key); } } } + protected getDefaultValue(_key: string, propertySchema: IRegisteredConfigurationPropertySchema): unknown { + return deepClone(propertySchema.default); + } + } export interface IPolicyConfiguration { diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 510ab589dc292e..ff9440d907a9b4 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -12,6 +12,9 @@ const _allApiProposals = { agentSessionsWorkspace: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.agentSessionsWorkspace.d.ts', }, + agentsWindowConfiguration: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.agentsWindowConfiguration.d.ts', + }, aiRelatedInformation: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.aiRelatedInformation.d.ts', }, diff --git a/src/vs/platform/update/common/update.config.contribution.ts b/src/vs/platform/update/common/update.config.contribution.ts index 8507c95c6aae47..2c63e9e679cfb6 100644 --- a/src/vs/platform/update/common/update.config.contribution.ts +++ b/src/vs/platform/update/common/update.config.contribution.ts @@ -76,7 +76,8 @@ configurationRegistry.registerConfiguration({ default: true, scope: ConfigurationScope.APPLICATION, description: localize('showReleaseNotes', "Show Release Notes after an update. The Release Notes are fetched from a Microsoft online service."), - tags: ['usesOnlineServices'] + tags: ['usesOnlineServices'], + agentsWindow: { default: false, readOnly: true }, }, 'update.showPostInstallInfo': { type: 'boolean', diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts index fc1821a80155d4..df941b6c04b567 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -5,82 +5,11 @@ import { Extensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; -import { ThemeSettingDefaults } from '../../../../workbench/services/themes/common/workbenchThemeService.js'; Registry.as(Extensions.Configuration).registerDefaultConfigurations([{ overrides: { - 'breadcrumbs.enabled': false, - - 'chat.experimentalSessionsWindowOverride': true, - 'chat.hookFilesLocations': { - '.claude/settings.local.json': false, - '.claude/settings.json': false, - '~/.claude/settings.json': false, - }, - 'chat.agent.maxRequests': 1000, 'chat.customizationsMenu.userStoragePath': '~/.copilot', - 'chat.viewSessions.enabled': false, - 'chat.implicitContext.suggestedContext': false, - 'chat.implicitContext.enabled': { 'panel': 'never' }, - 'chat.tools.terminal.enableAutoApprove': true, - - 'diffEditor.hideUnchangedRegions.enabled': true, - 'diffEditor.renderGutterMenu': false, - 'diffEditor.renderIndicators': false, - 'diffEditor.renderMarginRevertIcon': false, - 'diffEditor.renderSideBySide': true, - 'diffEditor.useInlineViewWhenSpaceIsLimited': true, - - 'extensions.ignoreRecommendations': true, - - 'files.autoSave': 'afterDelay', - - 'git.autofetch': true, - 'git.autorefresh': true, - 'git.branchRandomName.enable': true, - 'git.detectWorktrees': false, - 'git.showProgress': false, - - 'github.copilot.enable': { - 'markdown': true, - 'plaintext': true, - }, 'github.copilot.chat.claudeCode.enabled': true, - 'github.copilot.chat.cli.autoCommit.enabled': false, - 'github.copilot.chat.cli.branchSupport.enabled': true, - 'github.copilot.chat.cli.isolationOption.enabled': true, - 'github.copilot.chat.cli.sessionController.enabled': false, - 'github.copilot.chat.cli.lazyLoadSessionItem.enabled': false, - 'github.copilot.chat.cli.mcp.enabled': true, - 'github.copilot.chat.cli.remote.enabled': false, - 'github.copilot.chat.githubMcpServer.enabled': true, - 'github.copilot.chat.languageContext.typescript.enabled': true, - 'github.copilot.chat.cli.showExternalSessions': false, - - 'inlineChat.affordance': 'editor', - - 'search.quickOpen.includeHistory': false, - - 'task.notifyWindowOnTaskCompletion': -1, - - 'terminal.integrated.initialHint': false, - - 'workbench.browser.openLocalhostLinks': true, - 'workbench.browser.enableChatTools': true, - - 'workbench.editor.doubleClickTabToToggleEditorGroupSizes': 'maximize', - 'workbench.editor.restoreEditors': false, - 'update.showReleaseNotes': false, - 'workbench.notifications.position': 'bottom-right', - 'workbench.startupEditor': 'none', - 'workbench.tips.enabled': false, - 'workbench.layoutControl.type': 'toggles', - 'workbench.editor.useModal': 'all', - 'workbench.panel.showLabels': false, - 'workbench.colorTheme': ThemeSettingDefaults.COLOR_THEME_DARK, - - 'window.menuStyle': 'custom', - 'window.dialogStyle': 'custom', }, donotCache: true, preventExperimentOverride: true, diff --git a/src/vs/sessions/services/configuration/browser/configurationService.ts b/src/vs/sessions/services/configuration/browser/configurationService.ts index 01fb00152a6a1f..4aaacb896a325d 100644 --- a/src/vs/sessions/services/configuration/browser/configurationService.ts +++ b/src/vs/sessions/services/configuration/browser/configurationService.ts @@ -13,13 +13,13 @@ import { VSBuffer } from '../../../../base/common/buffer.js'; import { JSONPath, ParseError, parse } from '../../../../base/common/json.js'; import { applyEdits, setProperty } from '../../../../base/common/jsonEdit.js'; import { Edit, FormattingOptions } from '../../../../base/common/jsonFormatter.js'; -import { equals } from '../../../../base/common/objects.js'; +import { deepClone, equals } from '../../../../base/common/objects.js'; import { distinct, equals as arrayEquals } from '../../../../base/common/arrays.js'; import { OS, OperatingSystem } from '../../../../base/common/platform.js'; import { IConfigurationChange, IConfigurationChangeEvent, IConfigurationData, IConfigurationOverrides, IConfigurationUpdateOptions, IConfigurationUpdateOverrides, IConfigurationValue, ConfigurationTarget, isConfigurationOverrides, isConfigurationUpdateOverrides } from '../../../../platform/configuration/common/configuration.js'; import { ConfigurationChangeEvent, ConfigurationModel } from '../../../../platform/configuration/common/configurationModels.js'; import { DefaultConfiguration, IPolicyConfiguration, NullPolicyConfiguration, PolicyConfiguration } from '../../../../platform/configuration/common/configurations.js'; -import { Extensions, IConfigurationRegistry, keyFromOverrideIdentifiers } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { Extensions, IConfigurationRegistry, IRegisteredConfigurationPropertySchema, keyFromOverrideIdentifiers } from '../../../../platform/configuration/common/configurationRegistry.js'; import { IFileService, FileOperationError, FileOperationResult } from '../../../../platform/files/common/files.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IPolicyService, NullPolicyService } from '../../../../platform/policy/common/policy.js'; @@ -34,6 +34,17 @@ import { IUserDataProfileService } from '../../../../workbench/services/userData // Import to register configuration contributions import '../../../../workbench/services/configuration/browser/configurationService.js'; +class SessionsDefaultConfiguration extends DefaultConfiguration { + + protected override getDefaultValue(_key: string, propertySchema: IRegisteredConfigurationPropertySchema): unknown { + if (propertySchema.agentsWindow) { + return deepClone(propertySchema.agentsWindow.default); + } + return super.getDefaultValue(_key, propertySchema); + } + +} + export class ConfigurationService extends Disposable implements IWorkbenchConfigurationService { declare readonly _serviceBrand: undefined; @@ -43,6 +54,7 @@ export class ConfigurationService extends Disposable implements IWorkbenchConfig private readonly policyConfiguration: IPolicyConfiguration; private readonly userConfiguration: UserConfiguration; private readonly cachedFolderConfigs = this._register(new DisposableMap(new ResourceMap())); + private readonly agentsWindowReadOnlyKeys = new Set(); private readonly _onDidChangeConfiguration = this._register(new Emitter()); readonly onDidChangeConfiguration = this._onDidChangeConfiguration.event; @@ -66,9 +78,10 @@ export class ConfigurationService extends Disposable implements IWorkbenchConfig super(); this.settingsResource = userDataProfileService.currentProfile.settingsResource; - this.defaultConfiguration = this._register(new DefaultConfiguration(logService)); + this.defaultConfiguration = this._register(new SessionsDefaultConfiguration(logService)); this.policyConfiguration = policyService instanceof NullPolicyService ? new NullPolicyConfiguration() : this._register(new PolicyConfiguration(this.defaultConfiguration, policyService, logService)); - this.userConfiguration = this._register(new UserConfiguration(userDataProfileService.currentProfile.settingsResource, userDataProfileService.currentProfile.tasksResource, userDataProfileService.currentProfile.mcpResource, {}, fileService, uriIdentityService, logService)); + this.initAgentsWindowReadOnlyKeys(); + this.userConfiguration = this._register(new UserConfiguration(userDataProfileService.currentProfile.settingsResource, userDataProfileService.currentProfile.tasksResource, userDataProfileService.currentProfile.mcpResource, { exclude: [...this.agentsWindowReadOnlyKeys] }, fileService, uriIdentityService, logService)); this.configurationEditing = new ConfigurationEditing(fileService, this); this._configuration = new Configuration( @@ -150,6 +163,10 @@ export class ConfigurationService extends Disposable implements IWorkbenchConfig throw new Error(`Unable to write ${key} because it is configured in system policy.`); } + if (this.agentsWindowReadOnlyKeys.has(key)) { + throw new Error(`Unable to write ${key} because it is read-only in the Agents window.`); + } + // Remove the setting, if the value is same as default value if (equals(value, inspect.defaultValue)) { value = undefined; @@ -226,12 +243,35 @@ export class ConfigurationService extends Disposable implements IWorkbenchConfig // #endregion + private initAgentsWindowReadOnlyKeys(): void { + const properties = this.configurationRegistry.getConfigurationProperties(); + for (const key in properties) { + if (properties[key].agentsWindow?.readOnly) { + this.agentsWindowReadOnlyKeys.add(key); + } + } + } + + private updateAgentsWindowReadOnlyKeys(changedProperties: string[]): void { + const properties = this.configurationRegistry.getConfigurationProperties(); + for (const key of changedProperties) { + if (properties[key]?.agentsWindow?.readOnly) { + this.agentsWindowReadOnlyKeys.add(key); + } else { + this.agentsWindowReadOnlyKeys.delete(key); + } + } + } + // #region Configuration change handlers private onDefaultConfigurationChanged(defaults: ConfigurationModel, properties?: string[]): void { + if (properties) { + this.updateAgentsWindowReadOnlyKeys(properties); + } const previousData = this._configuration.toData(); const change = this._configuration.compareAndUpdateDefaultConfiguration(defaults, properties); - this._configuration.updateLocalUserConfiguration(this.userConfiguration.reparse()); + this._configuration.updateLocalUserConfiguration(this.userConfiguration.reparse({ exclude: [...this.agentsWindowReadOnlyKeys] })); for (const folder of this.workspaceService.getWorkspace().folders) { const folderConfiguration = this.cachedFolderConfigs.get(folder.uri); if (folderConfiguration) { diff --git a/src/vs/sessions/services/configuration/test/browser/configurationService.test.ts b/src/vs/sessions/services/configuration/test/browser/configurationService.test.ts index fdfd8a4f1e9f44..cec9d15503fe09 100644 --- a/src/vs/sessions/services/configuration/test/browser/configurationService.test.ts +++ b/src/vs/sessions/services/configuration/test/browser/configurationService.test.ts @@ -59,6 +59,24 @@ suite('Sessions ConfigurationService', () => { 'default': 'defaultValue', scope: ConfigurationScope.APPLICATION }, + 'sessionsConfigurationService.agentsWindowDefault': { + 'type': 'string', + 'default': 'originalDefault', + scope: ConfigurationScope.RESOURCE, + agentsWindow: { default: 'agentsDefault' } + }, + 'sessionsConfigurationService.agentsWindowReadOnly': { + 'type': 'string', + 'default': 'originalDefault', + scope: ConfigurationScope.RESOURCE, + agentsWindow: { default: 'readOnlyDefault', readOnly: true } + }, + 'sessionsConfigurationService.agentsWindowDefaultOnly': { + 'type': 'boolean', + 'default': false, + scope: ConfigurationScope.RESOURCE, + agentsWindow: { default: true } + }, } }); }); @@ -336,4 +354,64 @@ suite('Sessions ConfigurationService', () => { })); // #endregion + + // #region Agents Window Configuration + + test('agentsWindow.default overrides the default value', () => { + assert.strictEqual(testObject.getValue('sessionsConfigurationService.agentsWindowDefault'), 'agentsDefault'); + }); + + test('agentsWindow.default is reflected in inspect', () => { + const inspection = testObject.inspect('sessionsConfigurationService.agentsWindowDefault'); + assert.strictEqual(inspection.defaultValue, 'agentsDefault'); + }); + + test('agentsWindow.default with boolean value', () => { + assert.strictEqual(testObject.getValue('sessionsConfigurationService.agentsWindowDefaultOnly'), true); + }); + + test('agentsWindow.readOnly setting uses overridden default', () => { + assert.strictEqual(testObject.getValue('sessionsConfigurationService.agentsWindowReadOnly'), 'readOnlyDefault'); + }); + + test('agentsWindow.readOnly setting rejects writes', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await assert.rejects( + () => testObject.updateValue('sessionsConfigurationService.agentsWindowReadOnly', 'newValue'), + /read-only in the Agents window/ + ); + })); + + test('agentsWindow.readOnly setting ignores user settings file values', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await fileService.writeFile(userDataProfileService.currentProfile.settingsResource, VSBuffer.fromString('{ "sessionsConfigurationService.agentsWindowReadOnly": "userValue" }')); + await testObject.reloadConfiguration(); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.agentsWindowReadOnly'), 'readOnlyDefault'); + })); + + test('user settings override agentsWindow.default when not readOnly', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await fileService.writeFile(userDataProfileService.currentProfile.settingsResource, VSBuffer.fromString('{ "sessionsConfigurationService.agentsWindowDefault": "userValue" }')); + await testObject.reloadConfiguration(); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.agentsWindowDefault'), 'userValue'); + })); + + test('agentsWindow.readOnly setting added dynamically is picked up', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + configurationRegistry.registerConfiguration({ + 'id': '_test_sessions_dynamic', + 'type': 'object', + 'properties': { + 'sessionsConfigurationService.dynamicReadOnly': { + 'type': 'string', + 'default': 'originalDefault', + scope: ConfigurationScope.RESOURCE, + agentsWindow: { default: 'dynamicDefault', readOnly: true } + }, + } + }); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.dynamicReadOnly'), 'dynamicDefault'); + await assert.rejects( + () => testObject.updateValue('sessionsConfigurationService.dynamicReadOnly', 'newValue'), + /read-only in the Agents window/ + ); + })); + + // #endregion }); diff --git a/src/vs/workbench/api/common/configurationExtensionPoint.ts b/src/vs/workbench/api/common/configurationExtensionPoint.ts index 46304274ef0bf4..6c1492b69ad507 100644 --- a/src/vs/workbench/api/common/configurationExtensionPoint.ts +++ b/src/vs/workbench/api/common/configurationExtensionPoint.ts @@ -19,6 +19,7 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { SyncDescriptor } from '../../../platform/instantiation/common/descriptors.js'; import { MarkdownString } from '../../../base/common/htmlContent.js'; import product from '../../../platform/product/common/product.js'; +import { isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; const jsonRegistry = Registry.as(JSONExtensions.JSONContribution); const configurationRegistry = Registry.as(Extensions.Configuration); @@ -145,6 +146,21 @@ const configurationEntrySchema: IJSONSchema = { }, additionalItems: true, markdownDescription: nls.localize('scope.tags', 'A list of tags under which to place the setting. The tag can then be searched up in the Settings editor. For example, specifying the `experimental` tag allows one to find the setting by searching `@tag:experimental`.'), + }, + agentsWindow: { + type: 'object', + markdownDescription: nls.localize('scope.agentsWindow', "Configuration overrides for the Agents window. Allows specifying a different default value and read-only behavior for this setting when running in the Agents window.\n\n**Note**: This is a proposed API. To use it, extensions must include `agentsWindowConfiguration` in their `enabledApiProposals`."), + properties: { + 'default': { + description: nls.localize('scope.agentsWindow.default', 'The default value for this setting in the Agents window.'), + }, + readOnly: { + type: 'boolean', + description: nls.localize('scope.agentsWindow.readOnly', 'When true, this setting cannot be changed by the user in the Agents window.'), + default: false, + } + }, + additionalProperties: false } } } @@ -299,6 +315,10 @@ configurationExtPoint.setHandler((extensions, { added, removed }) => { mode: 'startup' }; } + if (propertyConfiguration.agentsWindow && !isProposedApiEnabled(extension.description, 'agentsWindowConfiguration')) { + extension.collector.error(nls.localize('config.property.agentsWindow.proposed', "Extension '{0}' CANNOT use 'agentsWindow' property on configuration '{1}' without enabling the 'agentsWindowConfiguration' API proposal.", extension.description.identifier.value, key)); + delete propertyConfiguration.agentsWindow; + } seenProperties.add(key); propertyConfiguration.scope = propertyConfiguration.scope ? parseScope(propertyConfiguration.scope.toString()) : ConfigurationScope.WINDOW; } diff --git a/src/vs/workbench/browser/parts/editor/breadcrumbs.ts b/src/vs/workbench/browser/parts/editor/breadcrumbs.ts index 2daec62c34b11c..348ec3327859e1 100644 --- a/src/vs/workbench/browser/parts/editor/breadcrumbs.ts +++ b/src/vs/workbench/browser/parts/editor/breadcrumbs.ts @@ -125,7 +125,8 @@ Registry.as(Extensions.Configuration).registerConfigurat 'breadcrumbs.enabled': { description: localize('enabled', "Enable/disable navigation breadcrumbs."), type: 'boolean', - default: true + default: true, + agentsWindow: { default: false }, }, 'breadcrumbs.filePath': { description: localize('filepath', "Controls whether and how file paths are shown in the breadcrumbs view."), diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index b164dc915f1371..95accefa0a8d1c 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -362,7 +362,8 @@ const registry = Registry.as(ConfigurationExtensions.Con localize('useModal.all', "All editors open in a centered modal overlay."), ], 'description': localize('useModal', "Controls whether editors open in a modal overlay."), - 'default': 'some' + 'default': 'some', + agentsWindow: { default: 'all' }, }, 'workbench.editor.swipeToNavigate': { 'type': 'boolean', @@ -400,7 +401,8 @@ const registry = Registry.as(ConfigurationExtensions.Con 'workbench.editor.restoreEditors': { 'type': 'boolean', 'description': localize('restoreOnStartup', "Controls whether editors are restored on startup. When disabled, only dirty editors will be restored from the previous session."), - 'default': true + 'default': true, + agentsWindow: { default: false, readOnly: true }, }, 'workbench.editor.splitInGroupLayout': { 'type': 'string', @@ -431,7 +433,8 @@ const registry = Registry.as(ConfigurationExtensions.Con localize('workbench.editor.doubleClickTabToToggleEditorGroupSizes.maximize', "All other editor groups are hidden and the current editor group is maximized to take up the entire editor area."), localize('workbench.editor.doubleClickTabToToggleEditorGroupSizes.expand', "The editor group takes as much space as possible by making all other editor groups as small as possible."), localize('workbench.editor.doubleClickTabToToggleEditorGroupSizes.off', "No editor group is resized when double clicking on a tab.") - ] + ], + agentsWindow: { default: 'maximize' }, }, 'workbench.editor.limit.enabled': { 'type': 'boolean', @@ -569,6 +572,7 @@ const registry = Registry.as(ConfigurationExtensions.Con 'type': 'boolean', 'default': true, 'description': localize('panelShowLabels', "Controls whether activity items in the panel title are shown as label or icon."), + agentsWindow: { default: false }, }, 'workbench.panel.defaultLocation': { 'type': 'string', @@ -777,11 +781,13 @@ const registry = Registry.as(ConfigurationExtensions.Con ], 'default': 'both', 'description': localize('layoutControlType', "Controls whether the layout control in the custom title bar is displayed as a single menu button or with multiple UI toggles."), + agentsWindow: { default: 'toggles' }, }, 'workbench.tips.enabled': { 'type': 'boolean', 'default': true, - 'description': localize('tips.enabled', "When enabled, will show the watermark tips when no editor is open.") + 'description': localize('tips.enabled', "When enabled, will show the watermark tips when no editor is open."), + agentsWindow: { default: false }, }, [LayoutSettings.SHADOWS]: { 'type': 'boolean', diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts index 3ac43114c5e389..b3a2d9e90fda30 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts @@ -562,7 +562,8 @@ Registry.as(ConfigurationExtensions.Configuration).regis value: localize('browser.enableChatTools', 'When enabled, chat agents can use browser tools to open and interact with pages in the Integrated Browser.') } }, - } + }, + agentsWindow: { default: true }, }, [AgentHostChatToolsEnabledSettingId]: { type: 'boolean', diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts index b7ef9c2df20737..f3ed5a35eb71c5 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts @@ -697,7 +697,8 @@ Registry.as(ConfigurationExtensions.Configuration).regis markdownDescription: localize( { comment: ['This is the description for a setting.'], key: 'browser.openLocalhostLinks' }, 'When enabled, localhost links (`localhost`, `127.0.0.1`, `[::1]`) and all-interfaces links (`0.0.0.0`, `[0:0:0:0:0:0:0:0]`, `[::]`) from the terminal, chat, and other sources will open in the Integrated Browser instead of the system browser.' - ) + ), + agentsWindow: { default: true }, } } }); diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index af383538981ee6..10afb3603743c3 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -216,6 +216,7 @@ configurationRegistry.registerConfiguration({ 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', @@ -298,12 +299,14 @@ configurationRegistry.registerConfiguration({ 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', @@ -655,6 +658,7 @@ configurationRegistry.registerConfiguration({ 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', @@ -1388,6 +1392,7 @@ configurationRegistry.registerConfiguration({ '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', @@ -1927,6 +1932,7 @@ class ChatAgentSettingContribution extends Disposable implements IWorkbenchContr 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 }, }, } }; diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts index 69bec710f502ac..b60f49efcf557b 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts @@ -163,7 +163,8 @@ Registry.as(ConfigurationExtensions.Configuration) 'extensions.ignoreRecommendations': { type: 'boolean', description: localize('extensionsIgnoreRecommendations', "When enabled, the notifications for extension recommendations will not be shown."), - default: false + default: false, + agentsWindow: { default: true, readOnly: true }, }, 'extensions.showRecommendationsOnlyOnDemand': { type: 'boolean', diff --git a/src/vs/workbench/contrib/files/browser/files.contribution.ts b/src/vs/workbench/contrib/files/browser/files.contribution.ts index c0b8db32da012d..574c2d8e5d406f 100644 --- a/src/vs/workbench/contrib/files/browser/files.contribution.ts +++ b/src/vs/workbench/contrib/files/browser/files.contribution.ts @@ -269,7 +269,8 @@ configurationRegistry.registerConfiguration({ ], 'default': isWeb ? AutoSaveConfiguration.AFTER_DELAY : AutoSaveConfiguration.OFF, 'markdownDescription': nls.localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'autoSave' }, "Controls [auto save](https://code.visualstudio.com/docs/editor/codebasics#_save-auto-save) of editors that have unsaved changes.", AutoSaveConfiguration.OFF, AutoSaveConfiguration.AFTER_DELAY, AutoSaveConfiguration.ON_FOCUS_CHANGE, AutoSaveConfiguration.ON_WINDOW_CHANGE, AutoSaveConfiguration.AFTER_DELAY), - scope: ConfigurationScope.LANGUAGE_OVERRIDABLE + scope: ConfigurationScope.LANGUAGE_OVERRIDABLE, + agentsWindow: { default: 'afterDelay' }, }, 'files.autoSaveDelay': { 'type': 'number', diff --git a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts index 6938c346c6b32b..e466989e7c2ce5 100644 --- a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts +++ b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts @@ -45,7 +45,8 @@ Registry.as(Extensions.Configuration).registerConfigurat experiment: { mode: 'auto' }, - tags: ['experimental'] + tags: ['experimental'], + agentsWindow: { default: 'editor' }, }, [InlineChatConfigKeys.FixDiagnostics]: { description: localize('fixDiagnostics', "Controls whether the Fix action is shown for diagnostics in the editor."), diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts index 4855acd4a3debc..c754843a640750 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts @@ -61,8 +61,9 @@ import { nullRange, Settings2EditorModel } from '../../../services/preferences/c import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js'; import { IUserDataSyncWorkbenchService } from '../../../services/userDataSync/common/userDataSync.js'; import { SuggestEnabledInput } from '../../codeEditor/browser/suggestEnabledInput/suggestEnabledInput.js'; -import { ADVANCED_SETTING_TAG, CONTEXT_AI_SETTING_RESULTS_AVAILABLE, CONTEXT_SETTINGS_EDITOR, CONTEXT_SETTINGS_ROW_FOCUS, CONTEXT_SETTINGS_SEARCH_FOCUS, CONTEXT_TOC_ROW_FOCUS, EMBEDDINGS_SEARCH_PROVIDER_NAME, ENABLE_LANGUAGE_FILTER, EXTENSION_FETCH_TIMEOUT_MS, EXTENSION_SETTING_TAG, FEATURE_SETTING_TAG, FILTER_MODEL_SEARCH_PROVIDER_NAME, getExperimentalExtensionToggleData, ID_SETTING_TAG, IPreferencesSearchService, ISearchProvider, LANGUAGE_SETTING_TAG, LLM_RANKED_SEARCH_PROVIDER_NAME, MODIFIED_SETTING_TAG, POLICY_SETTING_TAG, REQUIRE_TRUSTED_WORKSPACE_SETTING_TAG, SETTINGS_EDITOR_COMMAND_CLEAR_SEARCH_RESULTS, SETTINGS_EDITOR_COMMAND_SHOW_AI_RESULTS, SETTINGS_EDITOR_COMMAND_SUGGEST_FILTERS, SETTINGS_EDITOR_COMMAND_TOGGLE_AI_SEARCH, STRING_MATCH_SEARCH_PROVIDER_NAME, TF_IDF_SEARCH_PROVIDER_NAME, WorkbenchSettingsEditorSettings, WORKSPACE_TRUST_SETTING_TAG } from '../common/preferences.js'; +import { ADVANCED_SETTING_TAG, AGENTS_WINDOW_SETTING_TAG, CONTEXT_AI_SETTING_RESULTS_AVAILABLE, CONTEXT_SETTINGS_EDITOR, CONTEXT_SETTINGS_ROW_FOCUS, CONTEXT_SETTINGS_SEARCH_FOCUS, CONTEXT_TOC_ROW_FOCUS, EMBEDDINGS_SEARCH_PROVIDER_NAME, ENABLE_LANGUAGE_FILTER, EXTENSION_FETCH_TIMEOUT_MS, EXTENSION_SETTING_TAG, FEATURE_SETTING_TAG, FILTER_MODEL_SEARCH_PROVIDER_NAME, getExperimentalExtensionToggleData, ID_SETTING_TAG, IPreferencesSearchService, ISearchProvider, LANGUAGE_SETTING_TAG, LLM_RANKED_SEARCH_PROVIDER_NAME, MODIFIED_SETTING_TAG, POLICY_SETTING_TAG, REQUIRE_TRUSTED_WORKSPACE_SETTING_TAG, SETTINGS_EDITOR_COMMAND_CLEAR_SEARCH_RESULTS, SETTINGS_EDITOR_COMMAND_SHOW_AI_RESULTS, SETTINGS_EDITOR_COMMAND_SUGGEST_FILTERS, SETTINGS_EDITOR_COMMAND_TOGGLE_AI_SEARCH, STRING_MATCH_SEARCH_PROVIDER_NAME, TF_IDF_SEARCH_PROVIDER_NAME, WorkbenchSettingsEditorSettings, WORKSPACE_TRUST_SETTING_TAG } from '../common/preferences.js'; import { settingsHeaderBorder, settingsSashBorder, settingsTextInputBorder } from '../common/settingsEditorColorRegistry.js'; +import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import './media/settingsEditor2.css'; import { preferencesAiResultsIcon, preferencesClearInputIcon, preferencesFilterIcon } from './preferencesIcons.js'; import { SettingsTarget, SettingsTargetsWidget } from './preferencesWidgets.js'; @@ -262,7 +263,8 @@ export class SettingsEditor2 extends EditorPane { @IEditorProgressService private readonly editorProgressService: IEditorProgressService, @IUserDataProfileService userDataProfileService: IUserDataProfileService, @IKeybindingService private readonly keybindingService: IKeybindingService, - @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService + @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, ) { super(SettingsEditor2.ID, group, telemetryService, themeService, storageService); this.searchDelayer = this._register(new Delayer(200)); @@ -336,6 +338,9 @@ export class SettingsEditor2 extends EditorPane { if (ENABLE_LANGUAGE_FILTER && !SettingsEditor2.SUGGESTIONS.includes(`@${LANGUAGE_SETTING_TAG}`)) { SettingsEditor2.SUGGESTIONS.push(`@${LANGUAGE_SETTING_TAG}`); } + if (this.environmentService.isSessionsWindow && !SettingsEditor2.SUGGESTIONS.includes(`@${AGENTS_WINDOW_SETTING_TAG}`)) { + SettingsEditor2.SUGGESTIONS.push(`@${AGENTS_WINDOW_SETTING_TAG}`); + } this.inputChangeListener = this._register(new MutableDisposable()); } diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts index 35a683edd7f58b..39ee5f260ed834 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts @@ -369,6 +369,16 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { } }], }), { setupKeyboardEvents: true })); + } else if (element.isAgentsWindowReadOnly) { + this.scopeOverridesIndicator.element.style.display = 'inline'; + this.scopeOverridesIndicator.element.classList.add('setting-indicator'); + + this.scopeOverridesIndicator.label.text = '$(lock) ' + localize('agentsWindowReadOnlyLabelText', "Cannot be changed in Agents window"); + const content = localize('agentsWindowReadOnlyDescription', "This setting cannot be changed in the Agents window."); + this.scopeOverridesIndicator.disposables.add(this.hoverService.setupDelayedHover(this.scopeOverridesIndicator.element, { + ...this.defaultHoverOptions, + content, + }, { setupKeyboardEvents: true })); } else if (element.settingsTarget === ConfigurationTarget.USER_LOCAL && this.configurationService.isSettingAppliedForAllProfiles(element.setting.key)) { this.scopeOverridesIndicator.element.style.display = 'inline'; this.scopeOverridesIndicator.element.classList.add('setting-indicator'); @@ -564,6 +574,8 @@ export function getIndicatorsLabelAriaLabel(element: SettingsTreeSettingElement, if (element.hasPolicyValue) { ariaLabelSections.push(localize('policyDescriptionAccessible', "Managed by organization policy; setting value not applied")); + } else if (element.isAgentsWindowReadOnly) { + ariaLabelSections.push(localize('agentsWindowReadOnlyAccessible', "Cannot be changed in Agents window")); } else if (element.settingsTarget === ConfigurationTarget.USER_LOCAL && configurationService.isSettingAppliedForAllProfiles(element.setting.key)) { ariaLabelSections.push(localize('applicationSettingDescriptionAccessible', "Setting value retained when switching profiles")); } else { diff --git a/src/vs/workbench/contrib/preferences/browser/settingsTree.ts b/src/vs/workbench/contrib/preferences/browser/settingsTree.ts index f2b32036abb1b0..20eb3f981ca780 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsTree.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsTree.ts @@ -176,7 +176,7 @@ function getObjectDisplayValue(element: SettingsTreeSettingElement): IObjectData const data = element.isConfigured ? { ...elementDefaultValue, ...elementScopeValue } : - element.hasPolicyValue ? element.scopeValue : + element.hasPolicyValue || element.isAgentsWindowReadOnly ? element.scopeValue : elementDefaultValue; const { objectProperties, objectPatternProperties, objectAdditionalProperties } = element.setting; @@ -1313,7 +1313,7 @@ class SettingComplexObjectRenderer extends SettingComplexRenderer implements ITr showAddButton: false, isReadOnly: true, }); - template.button.parentElement?.classList.toggle('hide', dataElement.hasPolicyValue); + template.button.parentElement?.classList.toggle('hide', dataElement.hasPolicyValue || dataElement.isAgentsWindowReadOnly); super.renderValue(dataElement, template, onChange); } } @@ -1735,7 +1735,7 @@ abstract class SettingIncludeExcludeRenderer extends AbstractSettingRenderer imp protected renderValue(dataElement: SettingsTreeSettingElement, template: ISettingIncludeExcludeItemTemplate, onChange: (value: string) => void): void { const value = getIncludeExcludeDisplayValue(dataElement); - template.includeExcludeWidget.setValue(value, { isReadOnly: dataElement.hasPolicyValue }); + template.includeExcludeWidget.setValue(value, { isReadOnly: dataElement.hasPolicyValue || dataElement.isAgentsWindowReadOnly }); template.context = dataElement; template.elementDisposables.add(toDisposable(() => { template.includeExcludeWidget.cancelEdit(); @@ -1806,7 +1806,7 @@ abstract class AbstractSettingTextRenderer extends AbstractSettingRenderer imple protected renderValue(dataElement: SettingsTreeSettingElement, template: ISettingTextItemTemplate, onChange: (value: string) => void): void { template.onChange = undefined; template.inputBox.value = dataElement.value; - template.inputBox.setEnabled(!dataElement.hasPolicyValue); + template.inputBox.setEnabled(!dataElement.hasPolicyValue && !dataElement.isAgentsWindowReadOnly); template.inputBox.setAriaLabel(dataElement.setting.key); template.onChange = value => { if (!renderValidations(dataElement, template, false)) { @@ -1956,7 +1956,7 @@ class SettingEnumRenderer extends AbstractSettingRenderer implements ITreeRender template.selectBox.setOptions(displayOptions); template.selectBox.setAriaLabel(dataElement.setting.key); - template.selectBox.setEnabled(!dataElement.hasPolicyValue); + template.selectBox.setEnabled(!dataElement.hasPolicyValue && !dataElement.isAgentsWindowReadOnly); let idx = settingEnum.indexOf(dataElement.value); if (idx === -1) { @@ -2027,7 +2027,7 @@ class SettingNumberRenderer extends AbstractSettingRenderer implements ITreeRend dataElement.value.toString() : ''; template.inputBox.step = dataElement.valueType.includes('integer') ? '1' : 'any'; template.inputBox.setAriaLabel(dataElement.setting.key); - template.inputBox.setEnabled(!dataElement.hasPolicyValue); + template.inputBox.setEnabled(!dataElement.hasPolicyValue && !dataElement.isAgentsWindowReadOnly); template.onChange = value => { if (!renderValidations(dataElement, template, false)) { onChange(nullNumParseFn(value)); @@ -2110,7 +2110,7 @@ class SettingBoolRenderer extends AbstractSettingRenderer implements ITreeRender protected renderValue(dataElement: SettingsTreeSettingElement, template: ISettingBoolItemTemplate, onChange: (value: boolean) => void): void { template.onChange = undefined; template.checkbox.checked = dataElement.value; - if (dataElement.hasPolicyValue) { + if (dataElement.hasPolicyValue || dataElement.isAgentsWindowReadOnly) { template.checkbox.disable(); template.descriptionElement.classList.add('disabled'); } else { diff --git a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts index eaf1dfe189a06a..df7fb9e9c47100 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts @@ -20,7 +20,7 @@ import { APPLICATION_SCOPES, FOLDER_SCOPES, IWorkbenchConfigurationService, LOCA import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { IExtensionSetting, ISearchResult, ISetting, ISettingMatch, SettingMatchType, SettingValueType } from '../../../services/preferences/common/preferences.js'; import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js'; -import { ENABLE_EXTENSION_TOGGLE_SETTINGS, ENABLE_LANGUAGE_FILTER, MODIFIED_SETTING_TAG, POLICY_SETTING_TAG, REQUIRE_TRUSTED_WORKSPACE_SETTING_TAG, compareTwoNullableNumbers, wordifyKey } from '../common/preferences.js'; +import { AGENTS_WINDOW_SETTING_TAG, ENABLE_EXTENSION_TOGGLE_SETTINGS, ENABLE_LANGUAGE_FILTER, MODIFIED_SETTING_TAG, POLICY_SETTING_TAG, REQUIRE_TRUSTED_WORKSPACE_SETTING_TAG, compareTwoNullableNumbers, wordifyKey } from '../common/preferences.js'; import { SettingsTarget } from './preferencesWidgets.js'; import { ITOCEntry, tocData } from './settingsLayout.js'; @@ -154,6 +154,11 @@ export class SettingsTreeSettingElement extends SettingsTreeElement { */ hasPolicyValue = false; + /** + * Whether the setting is read-only in the Agents window. + */ + isAgentsWindowReadOnly = false; + tags?: Set; overriddenScopeList: string[] = []; overriddenDefaultsLanguageList: string[] = []; @@ -176,6 +181,7 @@ export class SettingsTreeSettingElement extends SettingsTreeElement { private readonly productService: IProductService, private readonly userDataProfileService: IUserDataProfileService, private readonly configurationService: IWorkbenchConfigurationService, + private readonly isSessionsWindow: boolean, ) { super(sanitizeId(parent.id + '_' + setting.key)); this.setting = setting; @@ -368,9 +374,19 @@ export class SettingsTreeSettingElement extends SettingsTreeElement { this.defaultValue = inspected.defaultValue; } + let hasAgentsWindowOverride = false; + if (this.isSessionsWindow) { + const property = Registry.as(Extensions.Configuration).getConfigurationProperties()[this.setting.key]; + hasAgentsWindowOverride = !!property?.agentsWindow; + this.isAgentsWindowReadOnly = !!property?.agentsWindow?.readOnly; + if (this.isAgentsWindowReadOnly) { + isConfigured = false; + } + } + this.value = displayValue; this.isConfigured = isConfigured; - if (isConfigured || this.setting.tags || this.tags || this.setting.restricted || this.hasPolicyValue) { + if (isConfigured || this.setting.tags || this.tags || this.setting.restricted || this.hasPolicyValue || hasAgentsWindowOverride) { // Don't create an empty Set for all 1000 settings, only if needed this.tags = new Set(); if (isConfigured) { @@ -386,6 +402,10 @@ export class SettingsTreeSettingElement extends SettingsTreeElement { if (this.hasPolicyValue) { this.tags.add(POLICY_SETTING_TAG); } + + if (hasAgentsWindowOverride) { + this.tags.add(AGENTS_WINDOW_SETTING_TAG); + } } } @@ -570,7 +590,8 @@ export class SettingsTreeModel implements IDisposable { @IWorkbenchConfigurationService private readonly _configurationService: IWorkbenchConfigurationService, @ILanguageService private readonly _languageService: ILanguageService, @IUserDataProfileService private readonly _userDataProfileService: IUserDataProfileService, - @IProductService private readonly _productService: IProductService + @IProductService private readonly _productService: IProductService, + @IWorkbenchEnvironmentService private readonly _environmentService: IWorkbenchEnvironmentService, ) { } @@ -686,7 +707,8 @@ export class SettingsTreeModel implements IDisposable { this._languageService, this._productService, this._userDataProfileService, - this._configurationService); + this._configurationService, + this._environmentService.isSessionsWindow); const nameElements = this._treeElementsBySettingName.get(setting.key) ?? []; nameElements.push(element); @@ -986,7 +1008,7 @@ export class SearchResultModel extends SettingsTreeModel { @IUserDataProfileService userDataProfileService: IUserDataProfileService, @IProductService productService: IProductService ) { - super(viewState, isWorkspaceTrusted, configurationService, languageService, userDataProfileService, productService); + super(viewState, isWorkspaceTrusted, configurationService, languageService, userDataProfileService, productService, environmentService); this.settingsOrderByTocIndex = settingsOrderByTocIndex; this.cachedUniqueSearchResults = new Map(); this.update({ id: 'searchResultModel', label: '' }); @@ -1217,6 +1239,11 @@ export function parseQuery(query: string): IParsedQuery { return ''; }); + query = query.replace(`@${AGENTS_WINDOW_SETTING_TAG}`, () => { + tags.push(AGENTS_WINDOW_SETTING_TAG); + return ''; + }); + // Handle @stable by excluding preview and experimental tags query = query.replace(/@stable/g, () => { tags.push('stable'); diff --git a/src/vs/workbench/contrib/preferences/common/preferences.ts b/src/vs/workbench/contrib/preferences/common/preferences.ts index 1e6fcb8202723b..2e97fb770e8d09 100644 --- a/src/vs/workbench/contrib/preferences/common/preferences.ts +++ b/src/vs/workbench/contrib/preferences/common/preferences.ts @@ -105,6 +105,7 @@ export const ID_SETTING_TAG = 'id:'; export const LANGUAGE_SETTING_TAG = 'lang:'; export const GENERAL_TAG_SETTING_TAG = 'tag:'; export const POLICY_SETTING_TAG = 'hasPolicy'; +export const AGENTS_WINDOW_SETTING_TAG = 'override:agentsWindow'; export const WORKSPACE_TRUST_SETTING_TAG = 'workspaceTrust'; export const REQUIRE_TRUSTED_WORKSPACE_SETTING_TAG = 'requireTrustedWorkspace'; export const ADVANCED_SETTING_TAG = 'advanced'; diff --git a/src/vs/workbench/contrib/search/browser/search.contribution.ts b/src/vs/workbench/contrib/search/browser/search.contribution.ts index 86d572b1aba912..dd1e1cd449307e 100644 --- a/src/vs/workbench/contrib/search/browser/search.contribution.ts +++ b/src/vs/workbench/contrib/search/browser/search.contribution.ts @@ -190,7 +190,8 @@ configurationRegistry.registerConfiguration({ 'search.quickOpen.includeHistory': { type: 'boolean', description: nls.localize('search.quickOpen.includeHistory', "Whether to include results from recently opened files in the file results for Quick Open."), - default: true + default: true, + agentsWindow: { default: false }, }, 'search.quickOpen.history.filterSortOrder': { type: 'string', diff --git a/src/vs/workbench/contrib/tasks/browser/task.contribution.ts b/src/vs/workbench/contrib/tasks/browser/task.contribution.ts index 4fa7237b82f4ea..1ad6a06d008e31 100644 --- a/src/vs/workbench/contrib/tasks/browser/task.contribution.ts +++ b/src/vs/workbench/contrib/tasks/browser/task.contribution.ts @@ -569,7 +569,8 @@ configurationRegistry.registerConfiguration({ type: 'integer', markdownDescription: nls.localize('task.NotifyWindowOnTaskCompletion', 'Controls the minimum task runtime in milliseconds before showing an OS notification when the task finishes while the window is not in focus. Set to -1 to disable notifications. Set to 0 to always show notifications. This includes a window badge as well as notification toast.'), default: 60000, - minimum: -1 + minimum: -1, + agentsWindow: { default: -1 }, }, [TaskSettingId.VerboseLogging]: { type: 'boolean', diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index 1907a58425b6f8..7d1842393dc01e 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -88,7 +88,8 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary Date: Tue, 5 May 2026 18:01:45 -0700 Subject: [PATCH 12/26] Fix stale BYOK models in model picker after API key removal (#314577) * Fix stale BYOK models in model picker after API key removal * Few fixes --- .../browser/widget/input/chatInputPart.ts | 12 ++- .../widget/input/chatModelSelectionLogic.ts | 30 ++++--- .../contrib/chat/common/languageModels.ts | 14 ++++ .../chatModelsViewModel.test.ts | 4 + .../input/chatModelSelectionLogic.test.ts | 83 ++++++++++++++++++- .../chat/test/common/languageModels.ts | 4 + 6 files changed, 133 insertions(+), 14 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 3aad8c6c0eb60d..0a594cc168dc03 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -1178,8 +1178,16 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge .map(modelId => ({ identifier: modelId, metadata: this.languageModelsService.lookupLanguageModel(modelId)! })); const contributedVendors = new Set(this.languageModelsService.getVendors().map(v => v.vendor)); - const models = mergeModelsWithCache(liveModels, cachedModels, contributedVendors); - if (liveModels.length > 0) { + const resolvedVendors = new Set(); + for (const v of contributedVendors) { + if (this.languageModelsService.hasResolvedVendor(v)) { + resolvedVendors.add(v); + } + } + const models = mergeModelsWithCache(liveModels, cachedModels, contributedVendors, resolvedVendors); + // Persist whenever we have any authoritative information — either live + // models, or at least one resolved vendor (so cache eviction sticks). + if (liveModels.length > 0 || resolvedVendors.size > 0) { this.storageService.store(CachedLanguageModelsKey, models, StorageScope.APPLICATION, StorageTarget.MACHINE); } return models; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelSelectionLogic.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelSelectionLogic.ts index d28039238ab697..75c73afe84444c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelSelectionLogic.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelSelectionLogic.ts @@ -222,24 +222,32 @@ export function resolveModelFromSyncState( } /** - * Merges live models with cached models per-vendor. - * For vendors whose models have resolved, uses live data. - * For vendors that are contributed but haven't resolved yet (startup race), keeps cached models. - * Vendors no longer contributed are evicted from cache. + * Merges live models with cached models per-vendor, evicting cache for vendors + * no longer contributed. + * + * - `resolvedVendors`: vendors whose providers have produced at least one + * result. An empty live list for these is authoritative (e.g. BYOK key + * removed) and their cache entries are dropped. + * - When no contributor info is available yet and there are no live models + * (startup / extension reload), the full cache is returned to avoid + * flickering the picker to empty. */ export function mergeModelsWithCache( liveModels: ILanguageModelChatMetadataAndIdentifier[], cachedModels: ILanguageModelChatMetadataAndIdentifier[], contributedVendors: Set, + resolvedVendors?: ReadonlySet, ): ILanguageModelChatMetadataAndIdentifier[] { - if (liveModels.length > 0) { - const liveVendors = new Set(liveModels.map(m => m.metadata.vendor)); - return [ - ...liveModels, - ...cachedModels.filter(m => !liveVendors.has(m.metadata.vendor) && contributedVendors.has(m.metadata.vendor)), - ]; + if (contributedVendors.size === 0 && liveModels.length === 0) { + return cachedModels; } - return cachedModels; + const liveVendors = new Set(liveModels.map(m => m.metadata.vendor)); + const usableCached = cachedModels.filter(m => + contributedVendors.has(m.metadata.vendor) && + !liveVendors.has(m.metadata.vendor) && + !resolvedVendors?.has(m.metadata.vendor) + ); + return [...liveModels, ...usableCached]; } /** diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 59001c7a24eb85..583eb528ea0d66 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -371,6 +371,14 @@ export interface ILanguageModelsService { getLanguageModelGroups(vendor: string): ILanguageModelsGroup[]; + /** + * Returns true if the given vendor's provider has completed at least one + * model resolution since registration. A `false` result indicates the + * vendor is still in a startup/reload race where its model list isn't yet + * authoritative — callers can fall back to a cached list in that case. + */ + hasResolvedVendor(vendor: string): boolean; + /** * Given a selector, returns a list of model identifiers * @param selector The selector to lookup for language models. If the selector is empty, all language models are returned. @@ -695,6 +703,7 @@ export class LanguageModelsService implements ILanguageModelsService { this._vendors.delete(item.vendor); this._providers.delete(item.vendor); this._clearModelCache(item.vendor); + this._modelsGroups.delete(item.vendor); removedVendorIds.push(item.vendor); } @@ -1006,6 +1015,10 @@ export class LanguageModelsService implements ILanguageModelsService { return this._modelsGroups.get(vendor) ?? []; } + hasResolvedVendor(vendor: string): boolean { + return this._modelsGroups.has(vendor); + } + async selectLanguageModels(selector: ILanguageModelChatSelector): Promise { if (selector.vendor) { @@ -1054,6 +1067,7 @@ export class LanguageModelsService implements ILanguageModelsService { return toDisposable(() => { this._logService.trace('[LM] UNregistered language model provider', vendor); this._clearModelCache(vendor); + this._modelsGroups.delete(vendor); this._providers.delete(vendor); modelChangeListener.dispose(); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts index 307e6955d7a8b6..c7db9f9868c8d4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts @@ -148,6 +148,10 @@ class MockLanguageModelsService implements ILanguageModelsService { return this.modelGroups.get(vendor) || []; } + hasResolvedVendor(vendor: string): boolean { + return this.modelGroups.has(vendor); + } + async removeLanguageModelsProviderGroup(vendorId: string, providerGroupName: string): Promise { } diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionLogic.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionLogic.test.ts index b25023e98d6dfc..60b976a416de5d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionLogic.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionLogic.test.ts @@ -34,8 +34,9 @@ function computeAvailableModels( sessionType: string | undefined, currentModeKind: ChatModeKind, location: ChatAgentLocation, + resolvedVendors?: ReadonlySet, ): ILanguageModelChatMetadataAndIdentifier[] { - const merged = mergeModelsWithCache(liveModels, cachedModels, contributedVendors); + const merged = mergeModelsWithCache(liveModels, cachedModels, contributedVendors, resolvedVendors); merged.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name)); return filterModelsForSession(merged, sessionType, currentModeKind, location); } @@ -640,6 +641,67 @@ suite('ChatModelSelectionLogic', () => { assert.strictEqual(result.length, 2); assert.deepStrictEqual(result.map(m => m.metadata.vendor).sort(), ['vendor-a', 'vendor-b']); }); + + test('evicts cached entries for a resolved vendor that returned zero models (BYOK delete)', () => { + // vendor-a is resolved with one live model; vendor-b is resolved with no live models + // (e.g. the user removed their BYOK API key). Cached vendor-b entries must NOT + // resurrect those models in the picker. + const liveA = createModel('a-model', 'A Model', { vendor: 'vendor-a' }); + const staleB = createModel('b-model', 'B Model', { vendor: 'vendor-b' }); + const result = mergeModelsWithCache( + [liveA], + [staleB], + new Set(['vendor-a', 'vendor-b']), + new Set(['vendor-a', 'vendor-b']), + ); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].metadata.vendor, 'vendor-a'); + }); + + test('keeps cached entries for an unresolved vendor (extension reload race)', () => { + // vendor-b is contributed but its provider hasn't completed a resolution yet + // (e.g. extension is mid-reload). Cache must bridge the gap so the picker + // keeps showing the user's previously-seen models. + const liveA = createModel('a-model', 'A Model', { vendor: 'vendor-a' }); + const cachedB = createModel('b-model', 'B Model', { vendor: 'vendor-b' }); + const result = mergeModelsWithCache( + [liveA], + [cachedB], + new Set(['vendor-a', 'vendor-b']), + new Set(['vendor-a']), // vendor-b not yet resolved + ); + assert.strictEqual(result.length, 2); + assert.deepStrictEqual(result.map(m => m.metadata.vendor).sort(), ['vendor-a', 'vendor-b']); + }); + + test('evicts cache for a resolved vendor even when all live models are zero', () => { + // Edge case: the only resolved vendor returns zero models (user deleted all + // configurations). Cache must be ignored — the picker should be empty. + const stale = createModel('b-model', 'B Model', { vendor: 'vendor-b' }); + const result = mergeModelsWithCache( + [], + [stale], + new Set(['vendor-b']), + new Set(['vendor-b']), + ); + assert.strictEqual(result.length, 0); + }); + + test('preserves full cache when no vendors are contributed yet (startup race)', () => { + // During startup or an extension reload, vendor descriptors may not be + // registered yet. contributedVendors is empty and so is resolvedVendors. + // We must NOT drop the cache — that would reset the user's selected model + // before the vendors come back. + const cachedA = createModel('a-model', 'A Model', { vendor: 'vendor-a' }); + const cachedB = createModel('b-model', 'B Model', { vendor: 'vendor-b' }); + const result = mergeModelsWithCache( + [], + [cachedA, cachedB], + new Set(), + new Set(), + ); + assert.deepStrictEqual(result.map(m => m.metadata.id).sort(), ['a-model', 'b-model']); + }); }); suite('model switching scenarios', () => { @@ -1046,6 +1108,25 @@ suite('ChatModelSelectionLogic', () => { ); assert.deepStrictEqual(result.map(m => m.metadata.id), ['tool']); }); + + test('startup/extension reload with no contributors yet preserves cache (production path)', () => { + // Mirrors chatInputPart.getAllMergedModels at a moment when getVendors() + // is temporarily empty (extension host reloading). resolvedVendors is + // also empty because nothing has resolved. The picker must continue to + // show cached models so the user's selection isn't reset. + const cachedA = createModel('a-model', 'A Model', { vendor: 'vendor-a' }); + const cachedB = createModel('b-model', 'B Model', { vendor: 'vendor-b' }); + const result = computeAvailableModels( + [], + [cachedA, cachedB], + new Set(), + undefined, + ChatModeKind.Ask, + ChatAgentLocation.Chat, + new Set(), + ); + assert.deepStrictEqual(result.map(m => m.metadata.id).sort(), ['a-model', 'b-model']); + }); }); suite('_syncFromModel edge cases', () => { diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.ts index 51ac576ae1a850..4a8d2d4ea7f067 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.ts @@ -63,6 +63,10 @@ export class NullLanguageModelsService implements ILanguageModelsService { return []; } + hasResolvedVendor(vendor: string): boolean { + return false; + } + async selectLanguageModels(selector: ILanguageModelChatSelector): Promise { return []; } From b884b25deef11be2ebb102e2b18dea3ddd30e3bc Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 6 May 2026 03:02:22 +0200 Subject: [PATCH 13/26] sessions: enable Search Editor and Problems panel in Agents window (#314599) - Enable Search Editor contribution in the Agents window - Extract search.searchEditor.* configuration properties from search.contribution.ts into searchEditor.contribution.ts - Create search.common.contribution.ts for shared service registrations (ISearchHistoryService, IReplaceService, INotebookSearchService) - Add Cmd+Shift+F keybinding to open Search Editor in Agents window - Enable Problems panel (markers) with WindowEnablement.Both - Fix panel part badge positioning in Agents window CSS Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/parts/media/panelPart.css | 4 ++ .../search/browser/search.contribution.ts | 14 ++++++ src/vs/sessions/sessions.common.main.ts | 8 ++++ .../markers/browser/markers.contribution.ts | 6 ++- .../browser/search.common.contribution.ts | 15 ++++++ .../search/browser/search.contribution.ts | 43 +---------------- .../browser/searchEditor.contribution.ts | 48 +++++++++++++++++++ 7 files changed, 94 insertions(+), 44 deletions(-) create mode 100644 src/vs/sessions/contrib/search/browser/search.contribution.ts create mode 100644 src/vs/workbench/contrib/search/browser/search.common.contribution.ts diff --git a/src/vs/sessions/browser/parts/media/panelPart.css b/src/vs/sessions/browser/parts/media/panelPart.css index 48e33b8d3c7007..88a9d4a66f0c21 100644 --- a/src/vs/sessions/browser/parts/media/panelPart.css +++ b/src/vs/sessions/browser/parts/media/panelPart.css @@ -62,6 +62,10 @@ background: var(--vscode-agentsPanel-background) !important; } +.agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .badge.compact .badge-content { + top: 11px; +} + /* Terminal body background */ .agent-sessions-workbench .part.panel .terminal-wrapper { background-color: var(--vscode-agentsPanel-background); diff --git a/src/vs/sessions/contrib/search/browser/search.contribution.ts b/src/vs/sessions/contrib/search/browser/search.contribution.ts new file mode 100644 index 00000000000000..7e1a16d0dd6ffd --- /dev/null +++ b/src/vs/sessions/contrib/search/browser/search.contribution.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; +import { KeybindingWeight, KeybindingsRegistry } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { OpenEditorCommandId } from '../../../../workbench/contrib/searchEditor/browser/constants.js'; + +KeybindingsRegistry.registerKeybindingRule({ + id: OpenEditorCommandId, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyF, + weight: KeybindingWeight.WorkbenchContrib, +}); diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index 5beb67a6878229..b0b1f216623fca 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -251,6 +251,11 @@ import '../workbench/contrib/inlineCompletions/browser/renameSymbolTrackerServic // Search Quick Access (file picker only, not the full search contribution) import '../workbench/contrib/search/browser/searchQuickAccess.contribution.js'; +// Search Editor +import '../workbench/contrib/searchEditor/browser/searchEditor.contribution.js'; +import '../workbench/contrib/search/browser/search.common.contribution.js'; +import './contrib/search/browser/search.contribution.js'; + // Sash import '../workbench/contrib/sash/browser/sash.contribution.js'; @@ -304,6 +309,9 @@ registerSingleton(IExtensionsWorkbenchService, ExtensionsWorkbenchService, Insta import '../workbench/contrib/output/browser/output.contribution.js'; import '../workbench/contrib/output/browser/outputView.js'; +// Problems View +import '../workbench/contrib/markers/browser/markers.contribution.js'; + // Terminal import '../workbench/contrib/terminal/terminal.all.js'; diff --git a/src/vs/workbench/contrib/markers/browser/markers.contribution.ts b/src/vs/workbench/contrib/markers/browser/markers.contribution.ts index 8c7055f1534793..43a883c508d202 100644 --- a/src/vs/workbench/contrib/markers/browser/markers.contribution.ts +++ b/src/vs/workbench/contrib/markers/browser/markers.contribution.ts @@ -23,7 +23,7 @@ import { IClipboardService } from '../../../../platform/clipboard/common/clipboa import { Disposable, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { IStatusbarEntryAccessor, IStatusbarService, StatusbarAlignment, IStatusbarEntry } from '../../../services/statusbar/browser/statusbar.js'; import { IMarkerService, MarkerStatistics } from '../../../../platform/markers/common/markers.js'; -import { ViewContainer, IViewContainersRegistry, Extensions as ViewContainerExtensions, ViewContainerLocation, IViewsRegistry } from '../../../common/views.js'; +import { ViewContainer, IViewContainersRegistry, Extensions as ViewContainerExtensions, ViewContainerLocation, IViewsRegistry, WindowEnablement } from '../../../common/views.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; import { getVisbileViewContextKey, FocusedViewContext } from '../../../common/contextkeys.js'; import { ViewPaneContainer } from '../../../browser/parts/views/viewPaneContainer.js'; @@ -137,6 +137,7 @@ const VIEW_CONTAINER: ViewContainer = Registry.as(ViewC order: 0, ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [Markers.MARKERS_CONTAINER_ID, { mergeViewWithContainerWhenSingleView: true }]), storageId: Markers.MARKERS_VIEW_STORAGE_ID, + windowEnablement: WindowEnablement.Both }, ViewContainerLocation.Panel, { doNotRegisterOpenCommand: true }); Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([{ @@ -151,7 +152,8 @@ Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews mnemonicTitle: localize({ key: 'miMarker', comment: ['&& denotes a mnemonic'] }, "&&Problems"), keybindings: { primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyM }, order: 0, - } + }, + windowEnablement: WindowEnablement.Both }], VIEW_CONTAINER); // workbench diff --git a/src/vs/workbench/contrib/search/browser/search.common.contribution.ts b/src/vs/workbench/contrib/search/browser/search.common.contribution.ts new file mode 100644 index 00000000000000..c729062eb3cbe7 --- /dev/null +++ b/src/vs/workbench/contrib/search/browser/search.common.contribution.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Common search service registrations shared between the main workbench and the Agents window. + +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { registerContributions as replaceContributions } from './replaceContributions.js'; +import { registerContributions as notebookSearchContributions } from './notebookSearch/notebookSearchContributions.js'; +import { ISearchHistoryService, SearchHistoryService } from '../common/searchHistoryService.js'; + +replaceContributions(); +notebookSearchContributions(); +registerSingleton(ISearchHistoryService, SearchHistoryService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/search/browser/search.contribution.ts b/src/vs/workbench/contrib/search/browser/search.contribution.ts index dd1e1cd449307e..b1c651ee10c6d0 100644 --- a/src/vs/workbench/contrib/search/browser/search.contribution.ts +++ b/src/vs/workbench/contrib/search/browser/search.contribution.ts @@ -14,12 +14,9 @@ import { Extensions as QuickAccessExtensions, IQuickAccessRegistry } from '../.. import { Registry } from '../../../../platform/registry/common/platform.js'; import { ViewPaneContainer } from '../../../browser/parts/views/viewPaneContainer.js'; import { Extensions as ViewExtensions, IViewContainersRegistry, IViewDescriptor, IViewsRegistry, ViewContainerLocation } from '../../../common/views.js'; -import { registerContributions as replaceContributions } from './replaceContributions.js'; -import { registerContributions as notebookSearchContributions } from './notebookSearch/notebookSearchContributions.js'; import { searchViewIcon } from './searchIcons.js'; import { SearchView } from './searchView.js'; import { registerContributions as searchWidgetContributions } from './searchWidget.js'; -import { ISearchHistoryService, SearchHistoryService } from '../common/searchHistoryService.js'; import { SearchViewModelWorkbenchService } from './searchTreeModel/searchModel.js'; import { ISearchViewModelWorkbenchService } from './searchTreeModel/searchViewModelWorkbenchService.js'; import { SearchSortOrder, SEARCH_EXCLUDE_CONFIG, VIEWLET_ID, ViewMode, VIEW_ID, DEFAULT_MAX_SEARCH_RESULTS, SemanticSearchBehavior } from '../../../services/search/common/search.js'; @@ -36,6 +33,7 @@ import './searchActionsRemoveReplace.js'; import './searchActionsTopBar.js'; import './searchActionsTextQuickAccess.js'; import './searchQuickAccess.contribution.js'; +import './search.common.contribution.js'; import { TEXT_SEARCH_QUICK_ACCESS_PREFIX, TextSearchQuickAccess } from './quickTextSearch/textSearchQuickAccess.js'; import { Extensions, IConfigurationMigrationRegistry } from '../../../common/configuration.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; @@ -43,10 +41,7 @@ import { AccessibleViewRegistry } from '../../../../platform/accessibility/brows import { SearchAccessibilityHelp } from './searchAccessibilityHelp.js'; registerSingleton(ISearchViewModelWorkbenchService, SearchViewModelWorkbenchService, InstantiationType.Delayed); -registerSingleton(ISearchHistoryService, SearchHistoryService, InstantiationType.Delayed); -replaceContributions(); -notebookSearchContributions(); searchWidgetContributions(); registerWorkbenchContribution2(SearchChatContextContribution.ID, SearchChatContextContribution, WorkbenchPhase.AfterRestored); @@ -288,42 +283,6 @@ configurationRegistry.registerConfiguration({ default: 300, markdownDescription: nls.localize('search.searchOnTypeDebouncePeriod', "When {0} is enabled, controls the timeout in milliseconds between a character being typed and the search starting. Has no effect when {0} is disabled.", '`#search.searchOnType#`') }, - 'search.searchEditor.doubleClickBehaviour': { - type: 'string', - enum: ['selectWord', 'goToLocation', 'openLocationToSide'], - default: 'goToLocation', - enumDescriptions: [ - nls.localize('search.searchEditor.doubleClickBehaviour.selectWord', "Double-clicking selects the word under the cursor."), - nls.localize('search.searchEditor.doubleClickBehaviour.goToLocation', "Double-clicking opens the result in the active editor group."), - nls.localize('search.searchEditor.doubleClickBehaviour.openLocationToSide', "Double-clicking opens the result in the editor group to the side, creating one if it does not yet exist."), - ], - markdownDescription: nls.localize('search.searchEditor.doubleClickBehaviour', "Configure effect of double-clicking a result in a search editor.") - }, - 'search.searchEditor.singleClickBehaviour': { - type: 'string', - enum: ['default', 'peekDefinition',], - default: 'default', - enumDescriptions: [ - nls.localize('search.searchEditor.singleClickBehaviour.default', "Single-clicking does nothing."), - nls.localize('search.searchEditor.singleClickBehaviour.peekDefinition', "Single-clicking opens a Peek Definition window."), - ], - markdownDescription: nls.localize('search.searchEditor.singleClickBehaviour', "Configure effect of single-clicking a result in a search editor.") - }, - 'search.searchEditor.reusePriorSearchConfiguration': { - type: 'boolean', - default: false, - markdownDescription: nls.localize({ key: 'search.searchEditor.reusePriorSearchConfiguration', comment: ['"Search Editor" is a type of editor that can display search results. "includes, excludes, and flags" refers to the "files to include" and "files to exclude" input boxes, and the flags that control whether a query is case-sensitive or a regex.'] }, "When enabled, new Search Editors will reuse the includes, excludes, and flags of the previously opened Search Editor.") - }, - 'search.searchEditor.defaultNumberOfContextLines': { - type: ['number', 'null'], - default: 1, - markdownDescription: nls.localize('search.searchEditor.defaultNumberOfContextLines', "The default number of surrounding context lines to use when creating new Search Editors. If using `#search.searchEditor.reusePriorSearchConfiguration#`, this can be set to `null` (empty) to use the prior Search Editor's configuration.") - }, - 'search.searchEditor.focusResultsOnSearch': { - type: 'boolean', - default: false, - markdownDescription: nls.localize('search.searchEditor.focusResultsOnSearch', "When a search is triggered, focus the Search Editor results instead of the Search Editor input.") - }, 'search.sortOrder': { type: 'string', enum: [SearchSortOrder.Default, SearchSortOrder.FileNames, SearchSortOrder.Type, SearchSortOrder.Modified, SearchSortOrder.CountDescending, SearchSortOrder.CountAscending], diff --git a/src/vs/workbench/contrib/searchEditor/browser/searchEditor.contribution.ts b/src/vs/workbench/contrib/searchEditor/browser/searchEditor.contribution.ts index 39bd4e4abb40cd..60f58b7624eede 100644 --- a/src/vs/workbench/contrib/searchEditor/browser/searchEditor.contribution.ts +++ b/src/vs/workbench/contrib/searchEditor/browser/searchEditor.contribution.ts @@ -37,6 +37,8 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { IWorkingCopyIdentifier } from '../../../services/workingCopy/common/workingCopy.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { getActiveElement } from '../../../../base/browser/dom.js'; +import * as nls from '../../../../nls.js'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; const OpenInEditorCommandId = 'search.action.openInEditor'; @@ -56,6 +58,52 @@ const CleanSearchEditorStateCommandId = 'cleanSearchEditorState'; const SelectAllSearchEditorMatchesCommandId = 'selectAllSearchEditorMatches'; +//#region Search Editor Configuration +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + id: 'search', + order: 13, + title: nls.localize('searchConfigurationTitle', "Search"), + type: 'object', + properties: { + 'search.searchEditor.doubleClickBehaviour': { + type: 'string', + enum: ['selectWord', 'goToLocation', 'openLocationToSide'], + default: 'goToLocation', + enumDescriptions: [ + nls.localize('search.searchEditor.doubleClickBehaviour.selectWord', "Double-clicking selects the word under the cursor."), + nls.localize('search.searchEditor.doubleClickBehaviour.goToLocation', "Double-clicking opens the result in the active editor group."), + nls.localize('search.searchEditor.doubleClickBehaviour.openLocationToSide', "Double-clicking opens the result in the editor group to the side, creating one if it does not yet exist."), + ], + markdownDescription: nls.localize('search.searchEditor.doubleClickBehaviour', "Configure effect of double-clicking a result in a search editor.") + }, + 'search.searchEditor.singleClickBehaviour': { + type: 'string', + enum: ['default', 'peekDefinition'], + default: 'default', + enumDescriptions: [ + nls.localize('search.searchEditor.singleClickBehaviour.default', "Single-clicking does nothing."), + nls.localize('search.searchEditor.singleClickBehaviour.peekDefinition', "Single-clicking opens a Peek Definition window."), + ], + markdownDescription: nls.localize('search.searchEditor.singleClickBehaviour', "Configure effect of single-clicking a result in a search editor.") + }, + 'search.searchEditor.reusePriorSearchConfiguration': { + type: 'boolean', + default: false, + markdownDescription: nls.localize({ key: 'search.searchEditor.reusePriorSearchConfiguration', comment: ['"Search Editor" is a type of editor that can display search results. "includes, excludes, and flags" refers to the "files to include" and "files to exclude" input boxes, and the flags that control whether a query is case-sensitive or a regex.'] }, "When enabled, new Search Editors will reuse the includes, excludes, and flags of the previously opened Search Editor.") + }, + 'search.searchEditor.defaultNumberOfContextLines': { + type: ['number', 'null'], + default: 1, + markdownDescription: nls.localize('search.searchEditor.defaultNumberOfContextLines', "The default number of surrounding context lines to use when creating new Search Editors. If using `#search.searchEditor.reusePriorSearchConfiguration#`, this can be set to `null` (empty) to use the prior Search Editor's configuration.") + }, + 'search.searchEditor.focusResultsOnSearch': { + type: 'boolean', + default: false, + markdownDescription: nls.localize('search.searchEditor.focusResultsOnSearch', "When a search is triggered, focus the Search Editor results instead of the Search Editor input.") + }, + } +}); +//#endregion //#region Editor Descriptior Registry.as(EditorExtensions.EditorPane).registerEditorPane( From 5d6fc5544dcc7606821b391d2aa384390a851dfd Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 6 May 2026 03:10:07 +0200 Subject: [PATCH 14/26] Open Workspace In Agents Window: select folder in new chat view (#314602) When 'Open in Agents Window' is triggered from the workbench, pass the current workspace folder URI to the agents window and pre-select it in the new chat workspace picker. Flow: - Action passes folderUri via openAgentsWindow API - Main process sends vscode:selectAgentsFolder IPC to agents window - Sessions contribution resolves folder via providers and selects it - Waits for provider registration with Eventually phase timeout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/native/common/native.ts | 4 +- .../electron-main/nativeHostMainService.ts | 11 ++-- .../platform/windows/electron-main/windows.ts | 2 +- .../electron-main/windowsMainService.ts | 11 +++- .../electron-browser/chat.contribution.ts | 58 +++++++++++++++++++ .../agentSessions/agentSessionsActions.ts | 5 +- .../electron-browser/workbenchTestServices.ts | 4 +- 7 files changed, 83 insertions(+), 12 deletions(-) diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index ff14e754a0d17b..d255fb14f3aad1 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -5,7 +5,7 @@ import { VSBuffer } from '../../../base/common/buffer.js'; import { Event } from '../../../base/common/event.js'; -import { URI } from '../../../base/common/uri.js'; +import { URI, UriComponents } from '../../../base/common/uri.js'; import { MessageBoxOptions, MessageBoxReturnValue, OpenDevToolsOptions, OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from '../../../base/parts/sandbox/common/electronTypes.js'; import { ISerializableCommandAction } from '../../action/common/action.js'; import { INativeOpenDialogOptions } from '../../dialogs/common/dialogs.js'; @@ -129,7 +129,7 @@ export interface ICommonNativeHostService { openWindow(options?: IOpenEmptyWindowOptions): Promise; openWindow(toOpen: IWindowOpenable[], options?: IOpenWindowOptions): Promise; - openAgentsWindow(): Promise; + openAgentsWindow(options?: { folderUri?: UriComponents }): Promise; /** * Launches the sibling application (host ↔ embedded). diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index 959ebdd602bcfc..1cfa2a170cb2cc 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -15,7 +15,7 @@ import { matchesSomeScheme, Schemas } from '../../../base/common/network.js'; import { dirname, join, posix, resolve, win32 } from '../../../base/common/path.js'; import { isLinux, isMacintosh, isWindows } from '../../../base/common/platform.js'; import { AddFirstParameterToFunctions } from '../../../base/common/types.js'; -import { URI } from '../../../base/common/uri.js'; +import { URI, UriComponents } from '../../../base/common/uri.js'; import { virtualMachineHint } from '../../../base/node/id.js'; import { Promises, SymlinkSupport } from '../../../base/node/pfs.js'; import { launchSiblingApp } from '../node/siblingApp.js'; @@ -305,12 +305,15 @@ export class NativeHostMainService extends Disposable implements INativeHostMain }, options); } - async openAgentsWindow(windowId: number | undefined): Promise { - await this.windowsMainService.openAgentsWindow({ + async openAgentsWindow(windowId: number | undefined, options?: { folderUri?: UriComponents }): Promise { + const windows = await this.windowsMainService.openAgentsWindow({ context: OpenContext.API, contextWindowId: windowId, cli: this.environmentMainService.args, - }); + }, options?.folderUri ? URI.revive(options.folderUri) : undefined); + if (windows.length > 0) { + windows[0].focus(); + } } async launchSiblingApp(_windowId: number | undefined, args?: string[]): Promise { diff --git a/src/vs/platform/windows/electron-main/windows.ts b/src/vs/platform/windows/electron-main/windows.ts index 0bfb8ccfa2ca71..3f6af0feb38f73 100644 --- a/src/vs/platform/windows/electron-main/windows.ts +++ b/src/vs/platform/windows/electron-main/windows.ts @@ -41,7 +41,7 @@ export interface IWindowsMainService { openExtensionDevelopmentHostWindow(extensionDevelopmentPath: string[], openConfig: IOpenConfiguration): Promise; openExistingWindow(window: ICodeWindow, openConfig: IOpenConfiguration): void; - openAgentsWindow(openConfig: IOpenConfiguration): Promise; + openAgentsWindow(openConfig: IOpenConfiguration, folderUri?: URI): Promise; sendToFocused(channel: string, ...args: unknown[]): void; sendToOpeningWindow(channel: string, ...args: unknown[]): void; diff --git a/src/vs/platform/windows/electron-main/windowsMainService.ts b/src/vs/platform/windows/electron-main/windowsMainService.ts index 9bd68e3d2df7d1..e9072892638d7e 100644 --- a/src/vs/platform/windows/electron-main/windowsMainService.ts +++ b/src/vs/platform/windows/electron-main/windowsMainService.ts @@ -292,11 +292,18 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic this.handleChatRequest(openConfig, [window]); } - async openAgentsWindow(openConfig: IOpenConfiguration): Promise { + async openAgentsWindow(openConfig: IOpenConfiguration, folderUri?: URI): Promise { this.logService.trace('windowsManager#openAgentsWindow'); // Open in a new browser window with the agent sessions workspace - return this.open(await this.ensureAgentsWindow(openConfig)); + const windows = await this.open(await this.ensureAgentsWindow(openConfig)); + + // Tell the agents window to select the given folder in the new chat workspace picker + if (folderUri && windows.length > 0) { + windows[0].sendWhenReady('vscode:selectAgentsFolder', CancellationToken.None, folderUri.toJSON()); + } + + return windows; } private async ensureAgentsWindow(openConfig: IOpenConfiguration): Promise { diff --git a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts index 913de098dc0826..826dfce5d98cf1 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts @@ -3,7 +3,65 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { ipcRenderer } from '../../../../base/parts/sandbox/electron-browser/globals.js'; +import { URI, UriComponents } from '../../../../base/common/uri.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; import { registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { ILifecycleService, LifecyclePhase } from '../../../../workbench/services/lifecycle/common/lifecycle.js'; +import { NewChatViewPane, SessionsViewId } from '../browser/newChatViewPane.js'; import { DebugAgentHostInDevToolsAction } from '../../../../workbench/contrib/chat/electron-browser/actions/debugAgentHostAction.js'; registerAction2(DebugAgentHostInDevToolsAction); + +class SelectAgentsFolderContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.selectAgentsFolder'; + + constructor( + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + @IViewsService private readonly viewsService: IViewsService, + @ILifecycleService private readonly lifecycleService: ILifecycleService, + ) { + super(); + ipcRenderer.on('vscode:selectAgentsFolder', (_: unknown, ...args: unknown[]) => { + const folderUri = URI.revive(args[0] as UriComponents); + this.selectFolder(folderUri); + }); + } + + private async selectFolder(folderUri: URI): Promise { + this.sessionsManagementService.openNewSessionView(); + + if (this.tryResolveAndSelect(folderUri)) { + return; + } + + // Provider not registered yet — wait for it, but give up at Eventually phase + const disposable = this.sessionsProvidersService.onDidChangeProviders(() => { + if (this.tryResolveAndSelect(folderUri)) { + disposable.dispose(); + } + }); + this.lifecycleService.when(LifecyclePhase.Eventually).then(() => disposable.dispose()); + } + + private tryResolveAndSelect(folderUri: URI): boolean { + for (const provider of this.sessionsProvidersService.getProviders()) { + const workspace = provider.resolveWorkspace(folderUri); + if (workspace) { + this.viewsService.openView(SessionsViewId).then(view => { + view?.selectWorkspace({ providerId: provider.id, workspace }); + }); + return true; + } + } + return false; + } +} + +registerWorkbenchContribution2(SelectAgentsFolderContribution.ID, SelectAgentsFolderContribution, WorkbenchPhase.BlockStartup); diff --git a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts index fb09d943baf26c..8bcbf1a55e51a5 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts @@ -19,6 +19,7 @@ import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { INativeHostService } from '../../../../../platform/native/common/native.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; import { TitleBarLeadingActionsGroup } from '../../../../browser/parts/titlebar/titlebarActions.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; @@ -49,7 +50,9 @@ export class OpenWorkspaceInAgentsWindowAction extends Action2 { async run(accessor: ServicesAccessor) { const nativeHostService = accessor.get(INativeHostService); - await nativeHostService.openAgentsWindow(); + const workspaceContextService = accessor.get(IWorkspaceContextService); + const folderUri = workspaceContextService.getWorkspace().folders[0]?.uri; + await nativeHostService.openAgentsWindow({ folderUri }); } } diff --git a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts index 61c5679167126b..f1c685b03794f9 100644 --- a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts @@ -9,7 +9,7 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { Event } from '../../../base/common/event.js'; import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { Schemas } from '../../../base/common/network.js'; -import { URI } from '../../../base/common/uri.js'; +import { URI, UriComponents } from '../../../base/common/uri.js'; import { IModelService } from '../../../editor/common/services/model.js'; import { ModelService } from '../../../editor/common/services/modelService.js'; import { TestConfigurationService } from '../../../platform/configuration/test/common/testConfigurationService.js'; @@ -103,7 +103,7 @@ export class TestNativeHostService implements INativeHostService { throw new Error('Method not implemented.'); } - async openAgentsWindow(): Promise { } + async openAgentsWindow(_options?: { folderUri?: UriComponents }): Promise { } async launchSiblingApp(_args?: string[]): Promise { } From 724775b927bbb677676a5ff3034ed8c7d1aae95d Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 6 May 2026 03:30:50 +0200 Subject: [PATCH 15/26] Skip passing folder URI to agents window for non-file schemes (#314607) Remote workspace folders (vscode-remote://) cannot be resolved by sessions providers, so only pass file:// URIs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../electron-browser/agentSessions/agentSessionsActions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts index 8bcbf1a55e51a5..38f6e87a53c6e4 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts @@ -19,6 +19,7 @@ import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { INativeHostService } from '../../../../../platform/native/common/native.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { Schemas } from '../../../../../base/common/network.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; import { TitleBarLeadingActionsGroup } from '../../../../browser/parts/titlebar/titlebarActions.js'; @@ -52,7 +53,7 @@ export class OpenWorkspaceInAgentsWindowAction extends Action2 { const nativeHostService = accessor.get(INativeHostService); const workspaceContextService = accessor.get(IWorkspaceContextService); const folderUri = workspaceContextService.getWorkspace().folders[0]?.uri; - await nativeHostService.openAgentsWindow({ folderUri }); + await nativeHostService.openAgentsWindow({ folderUri: folderUri?.scheme === Schemas.file ? folderUri : undefined }); } } From 48f0cac9e0951324e54a4a173b524c73188798bd Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Tue, 5 May 2026 18:34:32 -0700 Subject: [PATCH 16/26] sessions: add command to open events.jsonl for active Copilot CLI AH session (#314596) * sessions: add command to open events.jsonl for active Copilot CLI AH session Adds a new command `agentHost.openSessionEventsFile` ('Copilot CLI: Open Session Events File') accessible from the Command Palette. It opens the `events.jsonl` file for the currently active Copilot CLI chat session. Supported session types: - Local AH (scheme `agent-host-copilotcli`): opens `~/.copilot/session-state//events.jsonl` via IPathService.userHome. - Remote AH (scheme `remote--copilotcli`): looks up the connection via IRemoteAgentHostService, uses the host's `defaultDirectory` (reported during AHP handshake) to build a `vscode-agent-host://` URI, served by the existing remote AH filesystem provider. No new RPC needed. - EH CLI extension (scheme `copilotcli`): same local path as local AH. The command is gated on `ChatContextKeys.enabled && IsAgentHostSession` so it only appears when an AH session is active. The logic is in `openSessionEventsFileActions.ts` (pure `resolveEventsUri` helper + exported `Action2`) registered from `remoteAgentHost.contribution.ts`, which is loaded on both desktop and web. Unit tests cover all cases. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: rename command to 'Open Copilot CLI State File', move under Developer category (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/openSessionEventsFileActions.ts | 175 ++++++++++++++++++ .../browser/openSessionEventsFile.test.ts | 84 +++++++++ .../browser/remoteAgentHost.contribution.ts | 4 + 3 files changed, 263 insertions(+) create mode 100644 src/vs/sessions/contrib/agentHost/browser/openSessionEventsFileActions.ts create mode 100644 src/vs/sessions/contrib/agentHost/test/browser/openSessionEventsFile.test.ts diff --git a/src/vs/sessions/contrib/agentHost/browser/openSessionEventsFileActions.ts b/src/vs/sessions/contrib/agentHost/browser/openSessionEventsFileActions.ts new file mode 100644 index 00000000000000..b1a9b05ce52429 --- /dev/null +++ b/src/vs/sessions/contrib/agentHost/browser/openSessionEventsFileActions.ts @@ -0,0 +1,175 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { joinPath } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { agentHostAuthority, toAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js'; +import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { IsAgentHostSession } from './agentHostSkillButtons.js'; +import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; +import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; + +// Scheme conventions for `copilotcli` chat sessions: +// - Local AH: `agent-host-copilotcli:/` (LOCAL_RESOURCE_SCHEME_PREFIX + provider) +// - Remote AH: `remote--copilotcli:/` (remoteAgentHostSessionTypeId) +// - EH CLI ext: `copilotcli:/` (extension's own session type) +const COPILOT_CLI_PROVIDER = 'copilotcli'; +const LOCAL_AH_SCHEME = `agent-host-${COPILOT_CLI_PROVIDER}`; +const EH_CLI_SCHEME = COPILOT_CLI_PROVIDER; +const REMOTE_PROVIDER_PREFIX = 'remote-'; +const REMOTE_PROVIDER_SUFFIX = `-${COPILOT_CLI_PROVIDER}`; + +/** + * Builds the local `events.jsonl` URI under `~/.copilot/session-state//`. + * + * Used for both the local Agent Host Copilot CLI provider and the + * extension-host Copilot CLI provider, which share the same on-disk layout + * and the same chat session URI shape (`copilotcli:/`). + */ +export function buildLocalEventsUri(userHome: URI, rawSessionId: string): URI { + return joinPath(userHome, '.copilot', 'session-state', rawSessionId, 'events.jsonl'); +} + +/** + * Builds a `vscode-agent-host://` URI for the `events.jsonl` file inside + * the host's user home directory, using the connection's reported + * `defaultDirectory` (set from `os.homedir()` on the host during the + * AHP handshake). + * + * The path is joined at the URI-path level using forward slashes, which + * works for both POSIX hosts (`/home/me`) and Windows hosts whose + * `URI.file(os.homedir()).path` is also forward-slash-rooted (e.g. + * `/c:/Users/me`). Returns `undefined` if the host did not report a + * usable `defaultDirectory`. + */ +export function buildRemoteEventsUri(connection: IRemoteAgentHostConnectionInfo, rawSessionId: string): URI | undefined { + const homePath = connection.defaultDirectory; + if (!homePath) { + return undefined; + } + const trimmed = homePath.endsWith('/') ? homePath.slice(0, -1) : homePath; + const remoteFileUri = URI.from({ + scheme: 'file', + path: `${trimmed}/.copilot/session-state/${rawSessionId}/events.jsonl`, + }); + const authority = agentHostAuthority(connection.address); + return toAgentHostUri(remoteFileUri, authority); +} + +/** + * Parses the connection authority out of a remote AH chat session scheme + * of the form `remote--copilotcli`. Returns `undefined` for + * any other scheme, including the local `copilotcli` scheme. + */ +export function parseRemoteAuthorityFromScheme(scheme: string): string | undefined { + if (!scheme.startsWith(REMOTE_PROVIDER_PREFIX) || !scheme.endsWith(REMOTE_PROVIDER_SUFFIX)) { + return undefined; + } + const authority = scheme.slice(REMOTE_PROVIDER_PREFIX.length, scheme.length - REMOTE_PROVIDER_SUFFIX.length); + return authority || undefined; +} + +export type ResolveEventsUriResult = + | { readonly kind: 'ok'; readonly resource: URI } + | { readonly kind: 'no-session' } + | { readonly kind: 'unsupported-scheme'; readonly scheme: string } + | { readonly kind: 'remote-not-connected'; readonly authority: string } + | { readonly kind: 'remote-no-home'; readonly authority: string }; + +/** + * Pure resolver for tests. Translates a chat session resource into the + * `events.jsonl` URI for the corresponding Copilot CLI session, or + * returns a structured error. + */ +export function resolveEventsUri( + sessionResource: URI | undefined, + userHome: URI, + getConnectionByAuthority: (authority: string) => IRemoteAgentHostConnectionInfo | undefined, +): ResolveEventsUriResult { + if (!sessionResource) { + return { kind: 'no-session' }; + } + const rawId = sessionResource.path.startsWith('/') ? sessionResource.path.substring(1) : sessionResource.path; + if (!rawId) { + return { kind: 'no-session' }; + } + + if (sessionResource.scheme === LOCAL_AH_SCHEME || sessionResource.scheme === EH_CLI_SCHEME) { + return { kind: 'ok', resource: buildLocalEventsUri(userHome, rawId) }; + } + + const remoteAuthority = parseRemoteAuthorityFromScheme(sessionResource.scheme); + if (remoteAuthority) { + const connection = getConnectionByAuthority(remoteAuthority); + if (!connection) { + return { kind: 'remote-not-connected', authority: remoteAuthority }; + } + const resource = buildRemoteEventsUri(connection, rawId); + if (!resource) { + return { kind: 'remote-no-home', authority: remoteAuthority }; + } + return { kind: 'ok', resource }; + } + + return { kind: 'unsupported-scheme', scheme: sessionResource.scheme }; +} + +export class OpenSessionEventsFileAction extends Action2 { + + static readonly ID = 'agentHost.openSessionEventsFile'; + + constructor() { + super({ + id: OpenSessionEventsFileAction.ID, + title: localize2('openSessionEventsFile', "Open Copilot CLI State File"), + f1: true, + category: Categories.Developer, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, IsAgentHostSession), + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const sessionsManagementService = accessor.get(ISessionsManagementService); + const pathService = accessor.get(IPathService); + const remoteAgentHostService = accessor.get(IRemoteAgentHostService); + const editorService = accessor.get(IEditorService); + const notificationService = accessor.get(INotificationService); + + const sessionResource = sessionsManagementService.activeSession.get()?.resource; + const userHome = pathService.userHome({ preferLocal: true }); + + const result = resolveEventsUri( + sessionResource, + userHome, + authority => remoteAgentHostService.connections.find(c => agentHostAuthority(c.address) === authority), + ); + + switch (result.kind) { + case 'ok': + await editorService.openEditor({ resource: result.resource }); + return; + case 'no-session': + notificationService.info(localize('openSessionEventsFile.noSession', "No Copilot CLI session is active.")); + return; + case 'unsupported-scheme': + notificationService.info(localize('openSessionEventsFile.unsupported', "The active chat session is not a Copilot CLI session.")); + return; + case 'remote-not-connected': + notificationService.warn(localize('openSessionEventsFile.notConnected', "No active connection found for remote agent host '{0}'.", result.authority)); + return; + case 'remote-no-home': + notificationService.warn(localize('openSessionEventsFile.noHome', "Remote agent host '{0}' did not report a home directory.", result.authority)); + return; + } + } +} diff --git a/src/vs/sessions/contrib/agentHost/test/browser/openSessionEventsFile.test.ts b/src/vs/sessions/contrib/agentHost/test/browser/openSessionEventsFile.test.ts new file mode 100644 index 00000000000000..45e317304d21ce --- /dev/null +++ b/src/vs/sessions/contrib/agentHost/test/browser/openSessionEventsFile.test.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IRemoteAgentHostConnectionInfo, RemoteAgentHostConnectionStatus } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { resolveEventsUri } from '../../browser/openSessionEventsFileActions.js'; + +suite('openSessionEventsFile resolveEventsUri', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const userHome = URI.file('/home/me'); + + function makeRemoteConn(address: string, defaultDirectory: string | undefined): IRemoteAgentHostConnectionInfo { + return { + address, + name: address, + clientId: 'client-1', + defaultDirectory, + status: RemoteAgentHostConnectionStatus.connected, + }; + } + + test('local AH copilotcli session resolves to ~/.copilot/session-state//events.jsonl', () => { + const result = resolveEventsUri(URI.parse('agent-host-copilotcli:/abc'), userHome, () => undefined); + assert.deepStrictEqual( + { kind: result.kind, resource: result.kind === 'ok' ? result.resource.toString() : undefined }, + { kind: 'ok', resource: 'file:///home/me/.copilot/session-state/abc/events.jsonl' }, + ); + }); + + test('EH CLI copilotcli session resolves to ~/.copilot/session-state//events.jsonl', () => { + const result = resolveEventsUri(URI.parse('copilotcli:/abc'), userHome, () => undefined); + assert.deepStrictEqual( + { kind: result.kind, resource: result.kind === 'ok' ? result.resource.toString() : undefined }, + { kind: 'ok', resource: 'file:///home/me/.copilot/session-state/abc/events.jsonl' }, + ); + }); + + test('remote copilotcli session wraps host events.jsonl in vscode-agent-host URI', () => { + const conn = makeRemoteConn('localhost:4321', '/home/remote'); + const result = resolveEventsUri( + URI.parse('remote-localhost__4321-copilotcli:/xyz'), + userHome, + authority => authority === 'localhost__4321' ? conn : undefined, + ); + assert.deepStrictEqual( + { kind: result.kind, resource: result.kind === 'ok' ? result.resource.toString() : undefined }, + { kind: 'ok', resource: 'vscode-agent-host://localhost__4321/file/-/home/remote/.copilot/session-state/xyz/events.jsonl' }, + ); + }); + + test('remote scheme without an active connection returns remote-not-connected', () => { + const result = resolveEventsUri( + URI.parse('remote-myhost-copilotcli:/abc'), + userHome, + () => undefined, + ); + assert.deepStrictEqual(result, { kind: 'remote-not-connected', authority: 'myhost' }); + }); + + test('remote scheme without a defaultDirectory returns remote-no-home', () => { + const conn = makeRemoteConn('myhost', undefined); + const result = resolveEventsUri( + URI.parse('remote-myhost-copilotcli:/abc'), + userHome, + authority => authority === 'myhost' ? conn : undefined, + ); + assert.deepStrictEqual(result, { kind: 'remote-no-home', authority: 'myhost' }); + }); + + test('unknown scheme returns unsupported-scheme', () => { + const result = resolveEventsUri(URI.parse('claude:/abc'), userHome, () => undefined); + assert.deepStrictEqual(result, { kind: 'unsupported-scheme', scheme: 'claude' }); + }); + + test('missing session resource returns no-session', () => { + const result = resolveEventsUri(undefined, userHome, () => undefined); + assert.deepStrictEqual(result, { kind: 'no-session' }); + }); +}); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 8981bda2005bae..13b4a28d8d65de 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -28,6 +28,8 @@ import product from '../../../../platform/product/common/product.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { OpenSessionEventsFileAction } from '../../agentHost/browser/openSessionEventsFileActions.js'; import { AgentCustomizationSyncProvider } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationSyncProvider.js'; import { authenticateProtectedResources, AgentHostAuthTokenCache, resolveAuthenticationInteractively } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.js'; import { AgentHostLanguageModelProvider } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; @@ -588,6 +590,8 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc registerWorkbenchContribution2(RemoteAgentHostContribution.ID, RemoteAgentHostContribution, WorkbenchPhase.AfterRestored); +registerAction2(OpenSessionEventsFileAction); + Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ properties: { [RemoteAgentHostsEnabledSettingId]: { From 154bcccdf299a90df2c3ef3d629a25439cdbc926 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 6 May 2026 03:42:24 +0200 Subject: [PATCH 17/26] Remember previously selected session type and isolation mode (#314609) Persist the user's last-selected session type and isolation mode so they are restored when opening a new session, matching how the workspace picker retains its previously selected folder. Session type picker: - Add IStorageService to SessionTypePicker and MobileSessionTypePicker - Store selected type on pick, restore from storage when no session exists Isolation picker: - CopilotCLISession reads stored isolation mode as initial value - CopilotCLISession.setIsolationMode() persists to storage on change - Picker remains a pure UI widget with no storage dependency Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/mobileSessionTypePicker.ts | 7 +++++-- .../contrib/chat/browser/sessionTypePicker.ts | 12 +++++++++++- .../browser/copilotChatSessionsProvider.ts | 11 +++++++++-- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/mobileSessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/mobileSessionTypePicker.ts index 0b579a429083e5..b878d6e668a845 100644 --- a/src/vs/sessions/contrib/chat/browser/mobileSessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/mobileSessionTypePicker.ts @@ -5,10 +5,11 @@ import { localize } from '../../../../nls.js'; import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; -import { SessionTypePicker } from './sessionTypePicker.js'; +import { SessionTypePicker, STORAGE_KEY_LAST_SESSION_TYPE } from './sessionTypePicker.js'; import { isPhoneLayout } from '../../../browser/parts/mobile/mobileLayout.js'; import { IMobilePickerSheetItem, showMobilePickerSheet } from '../../../browser/parts/mobile/mobilePickerSheet.js'; @@ -29,9 +30,10 @@ export class MobileSessionTypePicker extends SessionTypePicker { @IActionWidgetService actionWidgetService: IActionWidgetService, @ISessionsManagementService sessionsManagementService: ISessionsManagementService, @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + @IStorageService storageService: IStorageService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, ) { - super(actionWidgetService, sessionsManagementService, sessionsProvidersService); + super(actionWidgetService, sessionsManagementService, sessionsProvidersService, storageService); } override render(container: HTMLElement): void { @@ -76,6 +78,7 @@ export class MobileSessionTypePicker extends SessionTypePicker { trigger.setAttribute('aria-expanded', 'false'); trigger.focus(); if (id !== undefined && id !== this._sessionType) { + this.storageService.store(STORAGE_KEY_LAST_SESSION_TYPE, id, StorageScope.PROFILE, StorageTarget.MACHINE); this._onDidSelectSessionType.fire(id); } }); diff --git a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts index 8335139f1efdc4..41c429095959d7 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts @@ -16,6 +16,9 @@ import { ISessionsProvidersService } from '../../../services/sessions/browser/se import { autorun } from '../../../../base/common/observable.js'; import { ISession, ISessionType } from '../../../services/sessions/common/session.js'; import { Emitter } from '../../../../base/common/event.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + +export const STORAGE_KEY_LAST_SESSION_TYPE = 'sessions.lastSelectedSessionType'; export class SessionTypePicker extends Disposable { @@ -33,9 +36,13 @@ export class SessionTypePicker extends Disposable { @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + @IStorageService protected readonly storageService: IStorageService, ) { super(); + // Restore the previously selected session type from storage + this._sessionType = this.storageService.get(STORAGE_KEY_LAST_SESSION_TYPE, StorageScope.PROFILE); + const refresh = (session: ISession | undefined) => { if (session) { const provider = this.sessionsProvidersService.getProvider(session.providerId); @@ -50,7 +57,9 @@ export class SessionTypePicker extends Disposable { } else { this._supportedSessionTypes = []; this._allProviderSessionTypes = []; - this._sessionType = undefined; + // Preserve the stored session type when no active session exists, + // so it can be used as the default for the next new session. + this._sessionType = this.storageService.get(STORAGE_KEY_LAST_SESSION_TYPE, StorageScope.PROFILE); } this._updateTriggerLabel(); }; @@ -132,6 +141,7 @@ export class SessionTypePicker extends Disposable { onSelect: (type) => { this.actionWidgetService.hide(); if (type.id !== this._sessionType) { + this.storageService.store(STORAGE_KEY_LAST_SESSION_TYPE, type.id, StorageScope.PROFILE, StorageTarget.MACHINE); this._onDidSelectSessionType.fire(type.id); } }, diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 3cb428c22977ae..ee94a2676fba40 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -42,10 +42,12 @@ import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IGitHubService } from '../../github/browser/githubService.js'; import { computePullRequestIcon, GitHubPullRequestState } from '../../github/common/types.js'; const SESSION_WORKSPACE_GROUP_GITHUB = localize('sessionWorkspaceGroup.github', "GitHub"); +const STORAGE_KEY_ISOLATION_MODE = 'sessions.isolationPicker.selectedMode'; export interface ICopilotChatSession { /** Globally unique session ID (`providerId:localId`). */ @@ -238,6 +240,7 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { providerId: string, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IGitService private readonly gitService: IGitService, + @IStorageService private readonly storageService: IStorageService, ) { super(); this.id = toSessionId(providerId, resource); @@ -255,8 +258,11 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { // Set ISessionData workspace observable this._workspaceData.set(sessionWorkspace, undefined); - this._isolationMode = 'worktree'; - this.setOption(ISOLATION_OPTION_ID, 'worktree'); + const storedMode = storageService.get(STORAGE_KEY_ISOLATION_MODE, StorageScope.PROFILE); + const initialMode: IsolationMode = storedMode === 'workspace' ? 'workspace' : 'worktree'; + this._isolationMode = initialMode; + this._isolationModeObservable.set(initialMode, undefined); + this.setOption(ISOLATION_OPTION_ID, initialMode); // Resolve git repository asynchronously this._resolveGitRepository(); @@ -354,6 +360,7 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { this._isolationMode = mode; this._isolationModeObservable.set(mode, undefined); this.setOption(ISOLATION_OPTION_ID, mode); + this.storageService.store(STORAGE_KEY_ISOLATION_MODE, mode, StorageScope.PROFILE, StorageTarget.MACHINE); if (mode === 'workspace') { // When switching to workspace mode, update the branch From 88511523c1fffe0163cf2a3360afc3aa8cece646 Mon Sep 17 00:00:00 2001 From: Paul Date: Tue, 5 May 2026 18:55:53 -0700 Subject: [PATCH 18/26] Simplify sign-in state for chat status dashboard (#314611) --- .../browser/chatStatus/chatStatusEntry.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts index cf329b280dc118..3e1185bd00e8d4 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts @@ -24,6 +24,7 @@ import product from '../../../../../platform/product/common/product.js'; import { isCompletionsEnabled } from '../../../../../editor/common/services/completionsEnablement.js'; import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { CHAT_SETUP_ACTION_ID } from '../actions/chatActions.js'; export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribution { @@ -149,10 +150,7 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu isProUser(entitlement) || // user is already pro entitlement === ChatEntitlement.Free // user is already free ) { - const signIn = localize('signInSetup', "Sign In"); - - text = `$(copilot) ${signIn}`; - ariaLabel = signIn; + return this.getSetupEntryProps(); } } else { const chatQuotaExceeded = this.chatEntitlementService.quotas.chat?.percentRemaining === 0; @@ -176,9 +174,7 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu // Signed out else if (this.chatEntitlementService.entitlement === ChatEntitlement.Unknown) { - const signIn = localize('signIn', "Sign In"); - text = `$(copilot) ${signIn}`; - ariaLabel = signIn; + return this.getSetupEntryProps(); } // Free Quota Exceeded @@ -241,6 +237,18 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu return baseResult; } + private getSetupEntryProps(): IStatusbarEntry { + const signInLabel = localize('signIn', "Sign In"); + return { + name: localize('chatStatus', "Copilot Status"), + text: `$(copilot) ${signInLabel}`, + ariaLabel: signInLabel, + command: CHAT_SETUP_ACTION_ID, + showInAllWindows: true, + kind: undefined, + }; + } + override dispose(): void { super.dispose(); From 88fa3d917ea2f76318a8ea0a0cc5006b320153a4 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Tue, 5 May 2026 18:57:04 -0700 Subject: [PATCH 19/26] agent test: warm tokenizer in beforeAll to fix summarization test flake (#314610) The first tokenizer use parses a ~3.6MB BPE file into a 200K-entry Map (O200K_base.tiktoken), which takes ~225ms locally and several seconds on slow CI machines. Because the tokenizer is a process-wide singleton, this one-time cost lands on whichever test calls tokenLength first. In summarization.spec.tsx that was 'continuation turns are not rendered in conversation history' (the first test in the suite), which then occasionally tripped vitest's 5s default test timeout on CI. Warm the tokenizer once in beforeAll so: - Per-test timing is predictable (281ms -> 36ms for the first test). - The cold-start cost is paid in suite setup, where it would surface as a clear 'Hook timed out' error rather than a misleading test timeout. Fixes #314078 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../prompts/node/agent/test/summarization.spec.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/summarization.spec.tsx b/extensions/copilot/src/extension/prompts/node/agent/test/summarization.spec.tsx index 2fbb815ec0718a..dec03abd2f66fc 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/summarization.spec.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/test/summarization.spec.tsx @@ -31,6 +31,7 @@ import { PromptRenderer } from '../../base/promptRenderer'; import { AgentPrompt, AgentPromptProps } from '../agentPrompt'; import { PromptRegistry } from '../promptRegistry'; import { ISessionTranscriptService, NullSessionTranscriptService } from '../../../../../platform/chat/common/sessionTranscriptService'; +import { ITokenizerProvider } from '../../../../../platform/tokenizer/node/tokenizer'; import { appendTranscriptHintToSummary, ConversationHistorySummarizationPrompt, extractSummary, stripToolSearchMessages, SummarizedConversationHistory, SummarizedConversationHistoryMetadata, SummarizedConversationHistoryPropsBuilder } from '../summarizedConversationHistory'; suite('Agent Summarization', () => { @@ -40,7 +41,7 @@ suite('Agent Summarization', () => { let conversation: Conversation; - beforeAll(() => { + beforeAll(async () => { const testDoc = createTextDocumentData(fileTsUri, 'line 1\nline 2\n\nline 4\nline 5', 'ts').document; const services = createExtensionUnitTestingServices(); @@ -57,6 +58,12 @@ suite('Agent Summarization', () => { accessor.get(IConfigurationService).setConfig(ConfigKey.CodeGenerationInstructions, [{ text: 'This is a test custom instruction file', } satisfies CodeGenerationTextInstruction]); + + // Warm up the tokenizer once so per-test timing is predictable. The first + // tokenizer use parses a ~3.6MB BPE file which can take seconds on slow CI + // machines and would otherwise be charged to whichever test runs first. + const endpoint = accessor.get(IInstantiationService).createInstance(MockEndpoint, undefined); + await accessor.get(ITokenizerProvider).acquireTokenizer(endpoint).tokenLength('warmup'); }); beforeEach(() => { From 89fc6394f59382617bf3940647ce06e9b0c1e9a2 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega <48293249+osortega@users.noreply.github.com> Date: Tue, 5 May 2026 21:20:50 -0700 Subject: [PATCH 20/26] Close mobile sidebar drawer when creating a new session (#314572) * Close mobile sidebar drawer when creating a new session On the agents mobile web layout, tapping the (+) buttons to create a new session left the sidebar drawer covering the viewport, hiding the new session view. Now the drawer is dismissed automatically: - The (+) button in the mobile titlebar (workbench.ts). - The per-workspace section (+) button inside the sessions list (NewSessionForWorkspaceAction). Both call sites are gated on `isWeb && isMobile` to match the existing `onSessionOpen` pattern in sessionsView.ts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR feedback: route mobile drawer close through proper path --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/browser/workbench.ts | 17 ++++++++++++++++- .../browser/views/sessionsViewActions.ts | 11 ++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 4298334831b16c..bd1a0254fe8cf3 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -56,6 +56,7 @@ import { NotificationsCenter } from '../../workbench/browser/parts/notifications import { NotificationsAlerts } from '../../workbench/browser/parts/notifications/notificationsAlerts.js'; import { NotificationsStatus } from '../../workbench/browser/parts/notifications/notificationsStatus.js'; import { registerNotificationCommands } from '../../workbench/browser/parts/notifications/notificationsCommands.js'; +import { CommandsRegistry } from '../../platform/commands/common/commands.js'; import { NotificationsToasts } from '../../workbench/browser/parts/notifications/notificationsToasts.js'; import { IMarkdownRendererService } from '../../platform/markdown/browser/markdownRenderer.js'; import { EditorMarkdownCodeBlockRenderer } from '../../editor/browser/widget/markdownRenderer/browser/editorMarkdownCodeBlockRenderer.js'; @@ -123,6 +124,8 @@ export interface IAgentWorkbenchLayoutService extends IWorkbenchLayoutService { export const IAgentWorkbenchLayoutService = refineServiceDecorator(IWorkbenchLayoutService); +export const CLOSE_MOBILE_SIDEBAR_DRAWER_COMMAND_ID = 'sessions.closeMobileSidebarDrawer'; + export class Workbench extends Disposable implements IAgentWorkbenchLayoutService { declare readonly _serviceBrand: undefined; @@ -518,6 +521,15 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic } private registerListeners(lifecycleService: ILifecycleService, storageService: IStorageService, configurationService: IConfigurationService, hostService: IHostService, dialogService: IDialogService): void { + // Command: close the mobile sidebar drawer (no-op outside phone layout). + // Routes through the proper close path so the mobile nav/history stack + // stays in sync (avoids extra Android back-button presses). + this._register(CommandsRegistry.registerCommand(CLOSE_MOBILE_SIDEBAR_DRAWER_COMMAND_ID, () => { + if (this.layoutPolicy.viewportClass.get() === 'phone') { + this.closeMobileSidebarDrawer(); + } + })); + // Configuration changes this._register(configurationService.onDidChangeConfiguration(e => this.updateFontAliasing(e, configurationService))); @@ -678,9 +690,12 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.toggleMobileSidebarDrawer(); })); - // New session: open new chat view + // New session: open new chat view and dismiss the sidebar drawer + // so the new session view becomes visible. createMobileTitlebar() is + // only invoked in phone layout, so closing the drawer here is safe. this.mobileTopBarDisposables.add(mobileTitlebar.onDidClickNewSession(() => { this.sessionsManagementService.openNewSessionView(); + this.closeMobileSidebarDrawer(); })); } diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index 82d291762478eb..073f12a21dff23 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -5,9 +5,10 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { isMobile, isWeb } from '../../../../../base/common/platform.js'; import { localize, localize2 } from '../../../../../nls.js'; import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; -import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; +import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -15,6 +16,7 @@ import { IQuickInputService } from '../../../../../platform/quickinput/common/qu import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; +import { CLOSE_MOBILE_SIDEBAR_DRAWER_COMMAND_ID } from '../../../../browser/workbench.js'; import { EditorsVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../../../workbench/common/contextkeys.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { AUX_WINDOW_GROUP } from '../../../../../workbench/services/editor/common/editorService.js'; @@ -347,12 +349,19 @@ registerAction2(class NewSessionForWorkspaceAction extends Action2 { } const sessionsManagementService = accessor.get(ISessionsManagementService); const viewsService = accessor.get(IViewsService); + const commandService = accessor.get(ICommandService); sessionsManagementService.openNewSessionView(); const view = await viewsService.openView(NewChatViewId, true); const workspace = context.sessions[0].workspace.get(); if (view && workspace) { view.selectWorkspace({ providerId: context.sessions[0].providerId, workspace }); } + // On mobile web, the sidebar drawer covers the viewport; close it so + // the new session view becomes visible after creation. Routes through + // the drawer-close command to keep the mobile nav/history stack in sync. + if (isWeb && isMobile) { + commandService.executeCommand(CLOSE_MOBILE_SIDEBAR_DRAWER_COMMAND_ID); + } } }); From dc53363e0c2e8eb1be3353cf531c09d91e3ea4aa Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Tue, 5 May 2026 21:35:55 -0700 Subject: [PATCH 21/26] agentHost: dedupe no-op root state actions (#314597) * Clean up agenthost icon handling Localized to the sessions provider * agentHost: dedupe no-op root state actions Skip envelope emission in AgentHostStateManager when a root action's reducer output deeply equals the previous root state. This prevents a feedback loop where a contribution re-publishes a root config value that already matches: the server would still fire a RootStateSubscription change, the contribution would react and re-dispatch, and so on. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: narrow root no-op dedupe to RootConfigChanged Per review feedback, only RootConfigChanged is dispatched as a true no-op (its reducer spreads values even when the patch matches). Other root actions like RootActiveSessionsChanged fire frequently (every turn start/complete) and always carry a real value, so the deep-compare was wasted work for them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: pre-check action patch to avoid no-op reducer allocation Instead of running the reducer and post-comparing output, inspect the RootConfigChanged action's patch against the current config values before calling rootReducer. This avoids allocating a new state object entirely for no-op dispatches, rather than allocating and discarding it. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: fix merge-mode no-op check and add serverSeq assertions - Merge-mode pre-check computes the merged result and deep-compares against current values, so a patch adding a new key (even with value undefined) is not mistakenly treated as a no-op - Test now asserts serverSeq does not advance on deduped (no-op) actions (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/agentHostStateManager.ts | 17 +++++++ .../test/node/agentHostStateManager.test.ts | 44 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 1fac09a2499b12..9767d928fbcfb3 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -6,6 +6,7 @@ import { RunOnceScheduler } from '../../../base/common/async.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; +import { equals } from '../../../base/common/objects.js'; import { ILogService } from '../../log/common/log.js'; import { ActionType, NotificationType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, RootAction, StateAction, TerminalAction, isRootAction, isSessionAction } from '../common/state/sessionActions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; @@ -333,6 +334,22 @@ export class AgentHostStateManager extends Disposable { let resultingState: unknown = undefined; // Apply to state if (isRootAction(action)) { + // `RootConfigChanged` can be a true no-op: the reducer merges/replaces + // values even when the patch matches the current state, and re-emitting + // it would cause clients observing rootState.onDidChange to react and + // potentially re-dispatch in a loop. Check the action's own patch + // against current values before running the reducer so we avoid + // allocating a new state object at all. + if (action.type === ActionType.RootConfigChanged && this._rootState.config) { + const current = this._rootState.config.values; + const patch = action.config; + const isNoOp = action.replace + ? equals(current, patch) + : equals({ ...current, ...patch }, current); + if (isNoOp) { + return this._rootState; + } + } this._rootState = rootReducer(this._rootState, action as RootAction, this._log); resultingState = this._rootState; } diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 64c0a5bf3e51ee..262604dc04f4d5 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -128,6 +128,50 @@ suite('AgentHostStateManager', () => { assert.deepStrictEqual(envelopes[0].origin, origin); }); + test('root action that does not change state is not emitted', () => { + const envelopes: ActionEnvelope[] = []; + disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); + + // First dispatch: introduces a new value, should emit. + manager.dispatchServerAction({ + type: ActionType.RootConfigChanged, + config: { 'my.setting': 'value-a' }, + }); + assert.strictEqual(envelopes.length, 1); + assert.strictEqual(manager.serverSeq, 1); + + // Second dispatch with the same value: should be deduped and not emit. + manager.dispatchServerAction({ + type: ActionType.RootConfigChanged, + config: { 'my.setting': 'value-a' }, + }); + assert.strictEqual(envelopes.length, 1); + assert.strictEqual(manager.serverSeq, 1, 'serverSeq must not advance on a no-op'); + + // Third dispatch with a deeply-equal but newly allocated object value: + // should also be deduped. + manager.dispatchServerAction({ + type: ActionType.RootConfigChanged, + config: { 'my.nested': { allow: ['x'], deny: [] } }, + }); + assert.strictEqual(envelopes.length, 2); + assert.strictEqual(manager.serverSeq, 2); + manager.dispatchServerAction({ + type: ActionType.RootConfigChanged, + config: { 'my.nested': { allow: ['x'], deny: [] } }, + }); + assert.strictEqual(envelopes.length, 2); + assert.strictEqual(manager.serverSeq, 2, 'serverSeq must not advance on a no-op'); + + // Real change still emits. + manager.dispatchServerAction({ + type: ActionType.RootConfigChanged, + config: { 'my.setting': 'value-b' }, + }); + assert.strictEqual(envelopes.length, 3); + assert.strictEqual(manager.serverSeq, 3); + }); + test('removeSession clears state without notification', () => { manager.createSession(makeSessionSummary()); From 50ab69d4855d057d654d3fd1408aef035cc9d762 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Tue, 5 May 2026 21:36:04 -0700 Subject: [PATCH 22/26] agents: fix spinner stuck after session completes (#314608) * fix: flush pending status notification before session eviction Race condition: _summaryNotifyScheduler debounces SessionSummaryChanged notifications by 100ms. When _maybeEvictIdleSession calls removeSession within that window after a turn completes, the session is removed from _dirtySummaries before the scheduler fires. Clients never receive the status=Idle notification and the spinner stays stuck. Fix: in removeSession, flush any dirty summary synchronously before clearing the session maps. This guarantees clients see the final status even when the session is evicted within the debounce window. For deleteSession (permanent removal), the forthcoming SessionRemoved notification supersedes any summary diff, so dirty summaries are dropped before removeSession is called to preserve the existing "no spurious SessionSummaryChanged on delete" invariant. scheduler fired (status=InProgress assert SessionSummaryChanged{status=Idle} emitted synchronously. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fixup: clarify removeSession JSDoc and remove unnecessary cast Per Copilot review on #314608: - JSDoc now clarifies removeSession won't emit SessionRemoved but may emit SessionSummaryChanged (synchronous flush side-effect) - Remove 'as unknown as string' cast on _dirtySummaries. thedelete other delete calls on the same maps use session directly Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/agentHostStateManager.ts | 86 +++++++++++++------ .../test/node/agentHostStateManager.test.ts | 45 ++++++++++ 2 files changed, 103 insertions(+), 28 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 9767d928fbcfb3..c3ac5758ca6cfb 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -238,9 +238,16 @@ export class AgentHostStateManager extends Disposable { } /** - * Removes a session from in-memory state without emitting a notification. + * Removes a session from in-memory state without emitting a + * {@link NotificationType.SessionRemoved} notification. * Use {@link deleteSession} when the session is being permanently deleted - * and clients need to be notified. + * and clients need to be notified of its removal. + * + * Any pending summary change is flushed synchronously before the session is + * torn down, so clients receive the final status (e.g. Idle after a turn + * completes) even when the session is evicted before the scheduler fires. + * A {@link NotificationType.SessionSummaryChanged} notification may therefore + * be emitted as a side-effect of this call. */ removeSession(session: URI): void { const state = this._sessionStates.get(session); @@ -248,6 +255,13 @@ export class AgentHostStateManager extends Disposable { return; } + // Flush any pending summary notification before tearing down state so + // that the final status (e.g. Idle) reaches clients even if the session + // is evicted within the scheduler's debounce window. + if (this._dirtySummaries.has(session)) { + this._flushSummaryNotificationFor(session); + } + // Clean up active turn tracking if (state.activeTurn) { this._activeTurnToSession.delete(state.activeTurn.id); @@ -272,6 +286,10 @@ export class AgentHostStateManager extends Disposable { */ deleteSession(session: URI): void { const wasAnnounced = this._lastNotifiedSummaries.has(session); + // Drop any pending summary diff: the forthcoming SessionRemoved notification + // supersedes it and we don't want to emit spurious SessionSummaryChanged + // events just before the session disappears from the client's view. + this._dirtySummaries.delete(session); this.removeSession(session); if (wasAnnounced) { this._onDidEmitNotification.fire({ @@ -402,33 +420,45 @@ export class AgentHostStateManager extends Disposable { private _flushSummaryNotifications(): void { for (const session of this._dirtySummaries) { - const state = this._sessionStates.get(session); - const lastNotified = this._lastNotifiedSummaries.get(session); - if (!state || !lastNotified || state.summary === lastNotified) { - continue; - } - - const current = state.summary; - const changes: Partial = {}; - if (current.title !== lastNotified.title) { changes.title = current.title; } - if (current.status !== lastNotified.status) { changes.status = current.status; } - if (current.activity !== lastNotified.activity) { changes.activity = current.activity; } - if (current.modifiedAt !== lastNotified.modifiedAt) { changes.modifiedAt = current.modifiedAt; } - if (current.project !== lastNotified.project) { changes.project = current.project; } - if (current.model !== lastNotified.model) { changes.model = current.model; } - if (current.workingDirectory !== lastNotified.workingDirectory) { changes.workingDirectory = current.workingDirectory; } - if (current.diffs !== lastNotified.diffs) { changes.diffs = current.diffs; } - - this._lastNotifiedSummaries.set(session, current); - - if (Object.keys(changes).length > 0) { - this._onDidEmitNotification.fire({ - type: NotificationType.SessionSummaryChanged, - session, - changes, - }); - } + this._flushSummaryNotificationFor(session); } this._dirtySummaries.clear(); } + + /** + * Emits a {@link NotificationType.SessionSummaryChanged} notification for + * `session` if its current summary differs from the last one sent to + * clients, then advances `_lastNotifiedSummaries` to the current summary. + * + * Does NOT remove `session` from `_dirtySummaries` — callers are + * responsible for that bookkeeping. + */ + private _flushSummaryNotificationFor(session: string): void { + const state = this._sessionStates.get(session); + const lastNotified = this._lastNotifiedSummaries.get(session); + if (!state || !lastNotified || state.summary === lastNotified) { + return; + } + + const current = state.summary; + const changes: Partial = {}; + if (current.title !== lastNotified.title) { changes.title = current.title; } + if (current.status !== lastNotified.status) { changes.status = current.status; } + if (current.activity !== lastNotified.activity) { changes.activity = current.activity; } + if (current.modifiedAt !== lastNotified.modifiedAt) { changes.modifiedAt = current.modifiedAt; } + if (current.project !== lastNotified.project) { changes.project = current.project; } + if (current.model !== lastNotified.model) { changes.model = current.model; } + if (current.workingDirectory !== lastNotified.workingDirectory) { changes.workingDirectory = current.workingDirectory; } + if (current.diffs !== lastNotified.diffs) { changes.diffs = current.diffs; } + + this._lastNotifiedSummaries.set(session, current); + + if (Object.keys(changes).length > 0) { + this._onDidEmitNotification.fire({ + type: NotificationType.SessionSummaryChanged, + session, + changes, + }); + } + } } diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 262604dc04f4d5..be2d65f0921fa8 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -426,6 +426,51 @@ suite('AgentHostStateManager', () => { assert.strictEqual(changed.length, 0, 'should not emit for deleted sessions'); }); }); + + test('removeSession flushes pending status=Idle notification before eviction', () => { + // Regression: when _maybeEvictIdleSession calls removeSession within the + // 100 ms scheduler window after a turn completes, the client must still + // receive a SessionSummaryChanged with status=Idle so the spinner clears. + // + // The key precondition is that _lastNotifiedSummaries already has + // status=InProgress (the scheduler must have fired after TurnStarted so + // the client knows the session is busy). Then TurnComplete flips the + // summary back to Idle and schedules another flush. If removeSession + // races with that 100 ms window the flush must happen synchronously. + return runWithFakedTimers({ useFakeTimers: true }, async () => { + manager.createSession(makeSessionSummary()); + manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + + // Start a turn → status becomes InProgress. + manager.dispatchServerAction({ + type: ActionType.SessionTurnStarted, + session: sessionUri, + turnId: 'turn-1', + userMessage: { text: 'hello' }, + }); + + // Let the scheduler fire so _lastNotifiedSummaries now has status=InProgress. + await new Promise(r => setTimeout(r, 150)); + + const notifications: INotification[] = []; + disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); + + // Turn completes — status flips back to Idle. This schedules a summary + // flush 100 ms later but we will call removeSession before it fires. + manager.dispatchServerAction({ + type: ActionType.SessionTurnComplete, + session: sessionUri, + turnId: 'turn-1', + }); + + // Simulate eviction within the 100 ms debounce window. + manager.removeSession(sessionUri); + + const changed = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged) as SessionSummaryChangedNotification[]; + assert.strictEqual(changed.length, 1, 'should emit SessionSummaryChanged synchronously in removeSession'); + assert.strictEqual(changed[0].changes.status, SessionStatus.Idle, 'status should be Idle so the spinner clears'); + }); + }); }); suite('Subagent URI helpers', () => { From fa89094190bd4561cf7dfdf8b15e11c251e0835c Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Tue, 5 May 2026 21:36:10 -0700 Subject: [PATCH 23/26] sessions: add command to collect agent host debug logs as zip (#314626) * sessions: add command to open events.jsonl for active Copilot CLI AH session Adds a new command `agentHost.openSessionEventsFile` ('Copilot CLI: Open Session Events File') accessible from the Command Palette. It opens the `events.jsonl` file for the currently active Copilot CLI chat session. Supported session types: - Local AH (scheme `agent-host-copilotcli`): opens `~/.copilot/session-state//events.jsonl` via IPathService.userHome. - Remote AH (scheme `remote--copilotcli`): looks up the connection via IRemoteAgentHostService, uses the host's `defaultDirectory` (reported during AHP handshake) to build a `vscode-agent-host://` URI, served by the existing remote AH filesystem provider. No new RPC needed. - EH CLI extension (scheme `copilotcli`): same local path as local AH. The command is gated on `ChatContextKeys.enabled && IsAgentHostSession` so it only appears when an AH session is active. The logic is in `openSessionEventsFileActions.ts` (pure `resolveEventsUri` helper + exported `Action2`) registered from `remoteAgentHost.contribution.ts`, which is loaded on both desktop and web. Unit tests cover all cases. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: rename command to 'Open Copilot CLI State File', move under Developer category (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * address Copilot review: use activeSession + clarify path comment - Replace IChatWidgetService.lastFocusedWidget lookup with ISessionsManagementService.activeSession, matching the source of the IsAgentHostSession precondition so they cannot diverge. - Reword buildRemoteEventsUri JSDoc: URI-path concatenation works on both POSIX and Windows hosts, not just POSIX. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: add command to collect agent host debug logs as zip Adds a new 'Developer: Collect Agent Host Debug Logs' command that gathers all logs relevant to debugging agent host issues and packages them as a zip file. Collected files: - events.jsonl for the active Copilot CLI session - IPC traffic output channel for the current session's agent host connection - Agent host process log (local sessions only) - Window log (rendererLog) - Shared process log The save dialog defaults to `ah-logs-.zip`. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: move CollectAgentHostDebugLogsAction to electron-browser to fix layer violation INativeHostService cannot be used from browser/ layer files. Extracted the action to a new electron-browser/ file and register it from the existing electron-browser chat contribution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../collectDebugLogsAction.ts | 169 ++++++++++++++++++ .../electron-browser/chat.contribution.ts | 2 + 2 files changed, 171 insertions(+) create mode 100644 src/vs/sessions/contrib/agentHost/electron-browser/collectDebugLogsAction.ts diff --git a/src/vs/sessions/contrib/agentHost/electron-browser/collectDebugLogsAction.ts b/src/vs/sessions/contrib/agentHost/electron-browser/collectDebugLogsAction.ts new file mode 100644 index 00000000000000..2f7d99efb18705 --- /dev/null +++ b/src/vs/sessions/contrib/agentHost/electron-browser/collectDebugLogsAction.ts @@ -0,0 +1,169 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { joinPath } from '../../../../base/common/resources.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { agentHostAuthority } from '../../../../platform/agentHost/common/agentHostUri.js'; +import { IAgentHostService } from '../../../../platform/agentHost/common/agentService.js'; +import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { INativeHostService } from '../../../../platform/native/common/native.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; +import { IOutputService } from '../../../../workbench/services/output/common/output.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { IsAgentHostSession } from '../browser/agentHostSkillButtons.js'; +import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; +import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; +import { resolveEventsUri, parseRemoteAuthorityFromScheme } from '../browser/openSessionEventsFileActions.js'; + +/** Output channel ID for the agent host process logger (forwarded via RemoteLoggerChannelClient). */ +const AGENT_HOST_LOGGER_CHANNEL_ID = 'agenthost'; +/** Output channel ID for the current window's renderer log. */ +const WINDOW_LOG_CHANNEL_ID = 'rendererLog'; +/** Output channel ID for the shared process compound log. */ +const SHARED_PROCESS_LOG_CHANNEL_ID = 'shared'; + +const LOCAL_AH_SCHEME = 'agent-host-copilotcli'; +const EH_CLI_SCHEME = 'copilotcli'; + +export class CollectAgentHostDebugLogsAction extends Action2 { + + static readonly ID = 'agentHost.collectDebugLogs'; + + constructor() { + super({ + id: CollectAgentHostDebugLogsAction.ID, + title: localize2('collectAgentHostDebugLogs', "Collect Agent Host Debug Logs"), + f1: true, + category: Categories.Developer, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, IsAgentHostSession), + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const sessionsManagementService = accessor.get(ISessionsManagementService); + const pathService = accessor.get(IPathService); + const remoteAgentHostService = accessor.get(IRemoteAgentHostService); + const agentHostService = accessor.get(IAgentHostService); + const outputService = accessor.get(IOutputService); + const fileService = accessor.get(IFileService); + const fileDialogService = accessor.get(IFileDialogService); + const nativeHostService = accessor.get(INativeHostService); + const notificationService = accessor.get(INotificationService); + const textModelService = accessor.get(ITextModelService); + + const sessionResource = sessionsManagementService.activeSession.get()?.resource; + const userHome = pathService.userHome({ preferLocal: true }); + + const eventsResult = resolveEventsUri( + sessionResource, + userHome, + authority => remoteAgentHostService.connections.find(c => agentHostAuthority(c.address) === authority), + ); + + switch (eventsResult.kind) { + case 'no-session': + notificationService.info(localize('collectDebugLogs.noSession', "No Copilot CLI session is active.")); + return; + case 'unsupported-scheme': + notificationService.info(localize('collectDebugLogs.unsupported', "The active chat session is not a Copilot CLI session.")); + return; + case 'remote-not-connected': + notificationService.warn(localize('collectDebugLogs.notConnected', "No active connection found for remote agent host '{0}'.", eventsResult.authority)); + return; + case 'remote-no-home': + notificationService.warn(localize('collectDebugLogs.noHome', "Remote agent host '{0}' did not report a home directory.", eventsResult.authority)); + return; + } + + const isLocal = sessionResource?.scheme === LOCAL_AH_SCHEME || sessionResource?.scheme === EH_CLI_SCHEME; + + // Collect all output channel IDs relevant for the current session's agent host. + const channelIds: string[] = []; + + if (isLocal) { + // IPC traffic log for the local agent host connection + channelIds.push(`agenthost.${agentHostService.clientId}`); + // Agent host process logger (forwarded from the utility process) + channelIds.push(AGENT_HOST_LOGGER_CHANNEL_ID); + } else { + const remoteAuthority = parseRemoteAuthorityFromScheme(sessionResource!.scheme); + if (remoteAuthority) { + const connection = remoteAgentHostService.connections.find(c => agentHostAuthority(c.address) === remoteAuthority); + if (connection) { + channelIds.push(`agenthost.${connection.clientId}`); + } + } + } + + // Always include the window and shared process logs + channelIds.push(WINDOW_LOG_CHANNEL_ID); + channelIds.push(SHARED_PROCESS_LOG_CHANNEL_ID); + + const files: { path: string; contents: string }[] = []; + + // 1. events.jsonl + try { + const content = await fileService.readFile(eventsResult.resource); + files.push({ path: 'events.jsonl', contents: content.value.toString() }); + } catch { + // File may not exist yet if the session never wrote any events + } + + // 2. Output channels + for (const channelId of channelIds) { + const channel = outputService.getChannel(channelId); + const descriptor = outputService.getChannelDescriptor(channelId); + if (!channel || !descriptor) { + continue; + } + const modelRef = await textModelService.createModelReference(channel.uri); + try { + const filename = `${descriptor.label.replace(/[/\\:*?"<>|]/g, '-')}.log`; + files.push({ path: filename, contents: modelRef.object.textEditorModel.getValue() }); + } finally { + modelRef.dispose(); + } + } + + if (files.length === 0) { + notificationService.notify({ + severity: Severity.Warning, + message: localize('collectDebugLogs.noFiles', "No log files were found for the active session."), + }); + return; + } + + const sessionTitle = sessionsManagementService.activeSession.get()?.title.get(); + const titleSlug = sessionTitle + ? `-${sessionTitle.replace(/[/\\:*?"<>|\s]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)}` + : ''; + const defaultUri = joinPath(await fileDialogService.defaultFilePath(), `ah-logs${titleSlug}.zip`); + const saveUri = await fileDialogService.showSaveDialog({ + title: localize('collectDebugLogs.saveDialogTitle', "Save Agent Host Debug Logs"), + defaultUri, + filters: [{ name: localize('collectDebugLogs.zipFilter', "Zip Archive"), extensions: ['zip'] }], + }); + + if (!saveUri) { + return; + } + + try { + await nativeHostService.createZipFile(saveUri, files); + } catch (error) { + notificationService.notify({ + severity: Severity.Error, + message: localize('collectDebugLogs.saveError', "Failed to save debug logs: {0}", error instanceof Error ? error.message : String(error)), + }); + } + } +} diff --git a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts index 826dfce5d98cf1..a4227c8aa39ed8 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts @@ -14,8 +14,10 @@ import { IViewsService } from '../../../../workbench/services/views/common/views import { ILifecycleService, LifecyclePhase } from '../../../../workbench/services/lifecycle/common/lifecycle.js'; import { NewChatViewPane, SessionsViewId } from '../browser/newChatViewPane.js'; import { DebugAgentHostInDevToolsAction } from '../../../../workbench/contrib/chat/electron-browser/actions/debugAgentHostAction.js'; +import { CollectAgentHostDebugLogsAction } from '../../agentHost/electron-browser/collectDebugLogsAction.js'; registerAction2(DebugAgentHostInDevToolsAction); +registerAction2(CollectAgentHostDebugLogsAction); class SelectAgentsFolderContribution extends Disposable implements IWorkbenchContribution { From a19e44159b20b113e7b2cde867fced4d8752175d Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Tue, 5 May 2026 21:36:29 -0700 Subject: [PATCH 24/26] Pass session capabilities to chat parser (#314027) * Pass session capabilities to chat parser (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Stub chat sessions service in chat service tests (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix comment typo and add agentIdSilent test coverage Agent-Logs-Url: https://github.com/microsoft/vscode/sessions/b7cba409-bf92-438f-adda-5d09865bc766 Co-authored-by: roblourens <323878+roblourens@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: roblourens <323878+roblourens@users.noreply.github.com> --- .../common/chatService/chatServiceImpl.ts | 22 ++- .../common/chatService/chatService.test.ts | 145 ++++++++++++++++++ 2 files changed, 162 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index b2c04973cd21b0..1284cd8a2c2082 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -33,7 +33,7 @@ import { IChatDebugService } from '../chatDebugService.js'; import { IMcpService } from '../../../mcp/common/mcpTypes.js'; import { awaitStatsForSession } from '../chat.js'; import { ChatPerfMark, clearChatMarks, markChat } from '../chatPerf.js'; -import { IChatAgentCommand, IChatAgentData, IChatAgentHistoryEntry, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../participants/chatAgents.js'; +import { IChatAgentAttachmentCapabilities, IChatAgentCommand, IChatAgentData, IChatAgentHistoryEntry, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../participants/chatAgents.js'; import { chatEditingSessionIsReady } from '../editing/chatEditingService.js'; import { ChatModel, ChatRequestModel, ChatRequestRemovalReason, IChatModel, IChatRequestModel, IChatRequestModeInfo, IChatRequestVariableData, IChatResponseModel, IExportableChatData, ISerializableChatData, ISerializableChatDataIn, ISerializableChatsData, ISerializedChatDataReference, normalizeSerializableChatData, toChatHistoryContent, updateRanges, ISerializableChatModelInputState } from '../model/chatModel.js'; import { ChatModelStore, IStartSessionProps } from '../model/chatModelStore.js'; @@ -663,13 +663,13 @@ export class ChatService extends Disposable implements IChatService { const parseAgentHostHistoryPrompt = (text: string, agent: IChatAgentData | undefined): IParsedChatRequest => { if (requestParser) { try { - const sessionCapabilities = this.chatSessionService.getCapabilitiesForSessionType(chatSessionType); + const attachmentCapabilities = this.getAttachmentCapabilitiesForParser(chatSessionType, agent); const parsed = requestParser.parseChatRequestWithReferences( EMPTY_REFERENCES, EMPTY_TOOL_ENABLEMENT_MAP, text, location, - { sessionType: chatSessionType, forcedAgent: agent, attachmentCapabilities: sessionCapabilities ?? agent?.capabilities }, + { sessionType: chatSessionType, forcedAgent: agent, attachmentCapabilities }, ); if (parsed.parts.length > 0) { return parsed; @@ -990,25 +990,37 @@ export class ChatService extends Disposable implements IChatService { } } + private getAttachmentCapabilitiesForParser(chatSessionType: string, agent: IChatAgentData | undefined): IChatAgentAttachmentCapabilities | undefined { + return this.chatSessionService.getCapabilitiesForSessionType(chatSessionType) ?? agent?.capabilities; + } + private parseChatRequest(sessionResource: URI, request: string, location: ChatAgentLocation, options: IChatSendRequestOptions | undefined): IParsedChatRequest { let parserContext = options?.parserContext; + let contextAgent = parserContext?.forcedAgent ?? parserContext?.selectedAgent; if (options?.agentId) { const agent = this.chatAgentService.getAgent(options.agentId); if (!agent) { throw new Error(`Unknown agent: ${options.agentId}`); } - parserContext = { selectedAgent: agent, mode: options.modeInfo?.kind }; + contextAgent = agent; + parserContext = { ...parserContext, selectedAgent: agent, mode: options.modeInfo?.kind }; const commandPart = options.slashCommand ? ` ${chatSubcommandLeader}${options.slashCommand}` : ''; request = `${chatAgentLeader}${agent.name}${commandPart} ${request}`; } else if (options?.agentIdSilent && !parserContext?.forcedAgent) { - // Resolve slash commandsin the context of locked participant so its subcommands take precedence over global + // Resolve slash commands in the context of locked participant so its subcommands take precedence over global // slash commands with the same name. const silentAgent = this.chatAgentService.getAgent(options.agentIdSilent); if (silentAgent) { + contextAgent = silentAgent; parserContext = { ...parserContext, forcedAgent: silentAgent }; } } + const attachmentCapabilities = parserContext?.attachmentCapabilities ?? this.getAttachmentCapabilitiesForParser(getChatSessionType(sessionResource), contextAgent); + if (attachmentCapabilities) { + parserContext = { ...parserContext, attachmentCapabilities }; + } + const parsedRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(sessionResource, request, location, parserContext); return parsedRequest; } diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 9e8a7c6125a4e2..ce33bc69f5ff37 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -11,6 +11,7 @@ import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { constObservable, ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { mockObject } from '../../../../../../base/test/common/mock.js'; import { assertSnapshot } from '../../../../../../base/test/common/snapshot.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; @@ -59,6 +60,7 @@ import { MockChatService } from './mockChatService.js'; import { ChatSessionOptionsMap, IChatSession, IChatSessionHistoryItem, IChatSessionsService } from '../../../common/chatSessionsService.js'; import { MockChatSessionsService } from '../mockChatSessionsService.js'; import { AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING, COPILOT_SKILL_URI_SCHEME, TROUBLESHOOT_SKILL_PATH } from '../../../common/promptSyntax/promptTypes.js'; +import { ChatRequestSlashPromptPart } from '../../../common/requestParser/chatParserTypes.js'; const chatAgentWithUsedContextId = 'ChatProviderWithUsedContext'; const chatAgentWithUsedContext: IChatAgent = { @@ -187,6 +189,7 @@ suite('ChatService', () => { instantiationService.stub(IChatSlashCommandService, testDisposables.add(instantiationService.createInstance(ChatSlashCommandService))); instantiationService.stub(IConfigurationService, new TestConfigurationService()); instantiationService.stub(IChatService, new MockChatService()); + instantiationService.stub(IChatSessionsService, new MockChatSessionsService()); instantiationService.stub(IEnvironmentService, { workspaceStorageHome: URI.file('/test/path/to/workspaceStorage') }); instantiationService.stub(ILifecycleService, { onWillShutdown: Event.None }); instantiationService.stub(IWorkspaceEditingService, { onDidEnterWorkspace: Event.None }); @@ -1123,6 +1126,148 @@ suite('ChatService', () => { ] ); }); + + test('sendRequest passes agent host session capabilities to the request parser', async () => { + const sessionType = 'agent-host-copilot'; + const sessionResource = URI.from({ scheme: sessionType, path: '/session' }); + + const mockSessionsService = new MockChatSessionsService(); + mockSessionsService.setContributions([{ + type: sessionType, + name: 'Agent Host', + displayName: 'Agent Host', + description: 'Agent Host', + capabilities: { supportsPromptAttachments: true }, + }]); + testDisposables.add(mockSessionsService.registerChatSessionContentProvider(sessionType, { + provideChatSessionContent: resource => Promise.resolve({ + sessionResource: resource, + history: [], + onWillDispose: Event.None, + dispose: () => { }, + }), + })); + instantiationService.stub(IChatSessionsService, mockSessionsService); + + const promptsService = mockObject()({ _serviceBrand: undefined }); + promptsService.isValidSlashCommandName.callsFake((command: string) => command === 'skill'); + instantiationService.stub(IPromptsService, promptsService); + + testDisposables.add(chatAgentService.registerAgent(sessionType, { ...getAgentData(sessionType), isDefault: true })); + testDisposables.add(chatAgentService.registerAgentImplementation(sessionType, { async invoke() { return {}; } })); + + const testService = createChatService(); + const ref = await testService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + + const response = await testService.sendRequest(sessionResource, '/skill plan', { agentId: sessionType }); + ChatSendResult.assertSent(response); + await response.data.responseCompletePromise; + + const model = testService.getSession(sessionResource) as ChatModel; + assert.deepStrictEqual(model.getRequests()[0].message.parts.map(part => ({ + type: part.constructor.name, + text: part instanceof ChatRequestSlashPromptPart ? part.name : undefined, + })), [ + { type: 'ChatRequestAgentPart', text: undefined }, + { type: 'ChatRequestTextPart', text: undefined }, + { type: 'ChatRequestSlashPromptPart', text: 'skill' }, + { type: 'ChatRequestTextPart', text: undefined }, + ]); + }); + + test('sendRequest with agentIdSilent passes agent host session capabilities to the request parser', async () => { + const sessionType = 'agent-host-copilot'; + const sessionResource = URI.from({ scheme: sessionType, path: '/session-silent' }); + + const mockSessionsService = new MockChatSessionsService(); + mockSessionsService.setContributions([{ + type: sessionType, + name: 'Agent Host', + displayName: 'Agent Host', + description: 'Agent Host', + capabilities: { supportsPromptAttachments: true }, + }]); + testDisposables.add(mockSessionsService.registerChatSessionContentProvider(sessionType, { + provideChatSessionContent: resource => Promise.resolve({ + sessionResource: resource, + history: [], + onWillDispose: Event.None, + dispose: () => { }, + }), + })); + instantiationService.stub(IChatSessionsService, mockSessionsService); + + const promptsService = mockObject()({ _serviceBrand: undefined }); + promptsService.isValidSlashCommandName.callsFake((command: string) => command === 'skill'); + instantiationService.stub(IPromptsService, promptsService); + + testDisposables.add(chatAgentService.registerAgent(sessionType, { ...getAgentData(sessionType), isDefault: true })); + testDisposables.add(chatAgentService.registerAgentImplementation(sessionType, { async invoke() { return {}; } })); + + const testService = createChatService(); + const ref = await testService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + + const response = await testService.sendRequest(sessionResource, '/skill plan', { agentIdSilent: sessionType }); + ChatSendResult.assertSent(response); + await response.data.responseCompletePromise; + + const model = testService.getSession(sessionResource) as ChatModel; + assert.deepStrictEqual(model.getRequests()[0].message.parts.map(part => ({ + type: part.constructor.name, + text: part instanceof ChatRequestSlashPromptPart ? part.name : undefined, + })), [ + { type: 'ChatRequestSlashPromptPart', text: 'skill' }, + { type: 'ChatRequestTextPart', text: undefined }, + ]); + }); + + test('loadRemoteSession passes agent host session capabilities to the request parser', async () => { + const sessionType = 'agent-host-copilot'; + const sessionResource = URI.from({ scheme: sessionType, path: '/session-with-history' }); + + const mockSessionsService = new MockChatSessionsService(); + mockSessionsService.setContributions([{ + type: sessionType, + name: 'Agent Host', + displayName: 'Agent Host', + description: 'Agent Host', + capabilities: { supportsPromptAttachments: true }, + }]); + testDisposables.add(mockSessionsService.registerChatSessionContentProvider(sessionType, { + provideChatSessionContent: resource => Promise.resolve({ + sessionResource: resource, + history: [{ type: 'request', prompt: '/skill plan', participant: sessionType }], + onWillDispose: Event.None, + dispose: () => { }, + }), + })); + instantiationService.stub(IChatSessionsService, mockSessionsService); + + const promptsService = mockObject()({ _serviceBrand: undefined }); + promptsService.isValidSlashCommandName.callsFake((command: string) => command === 'skill'); + instantiationService.stub(IPromptsService, promptsService); + + testDisposables.add(chatAgentService.registerAgent(sessionType, { ...getAgentData(sessionType), isDefault: true })); + testDisposables.add(chatAgentService.registerAgentImplementation(sessionType, { async invoke() { return {}; } })); + + const testService = createChatService(); + const ref = await testService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + + assert.deepStrictEqual(ref.object.getRequests()[0].message.parts.map(part => ({ + type: part.constructor.name, + text: part instanceof ChatRequestSlashPromptPart ? part.name : undefined, + })), [ + { type: 'ChatRequestSlashPromptPart', text: 'skill' }, + { type: 'ChatRequestTextPart', text: undefined }, + ]); + }); + test('troubleshoot skill via attachedContext is blocked when fileLogging.enabled is off', async () => { const configService = instantiationService.get(IConfigurationService) as TestConfigurationService; await configService.setUserConfiguration(AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING, false); From 18a02327e146205cf696f8a5babbd036b6a8f743 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega <48293249+osortega@users.noreply.github.com> Date: Tue, 5 May 2026 21:51:16 -0700 Subject: [PATCH 25/26] Agents web: remove theme service for now (#314638) --- .../vscode/browser/themeImporterService.ts | 27 +++++++++++++++++++ src/vs/sessions/sessions.web.main.ts | 1 + 2 files changed, 28 insertions(+) create mode 100644 src/vs/sessions/services/vscode/browser/themeImporterService.ts diff --git a/src/vs/sessions/services/vscode/browser/themeImporterService.ts b/src/vs/sessions/services/vscode/browser/themeImporterService.ts new file mode 100644 index 00000000000000..63bf1f07b6e149 --- /dev/null +++ b/src/vs/sessions/services/vscode/browser/themeImporterService.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { IThemeImporterService, IThemePreviewResult } from '../common/themeImporter.js'; + +/** + * Browser/web no-op implementation of {@link IThemeImporterService}. The web + * variant of the Agents app does not have access to a parent VS Code + * installation, so theme importing is unavailable. + */ +class BrowserThemeImporterService implements IThemeImporterService { + + declare readonly _serviceBrand: undefined; + + async getVSCodeTheme(): Promise { + return undefined; + } + + async previewVSCodeTheme(): Promise { + return undefined; + } +} + +registerSingleton(IThemeImporterService, BrowserThemeImporterService, InstantiationType.Delayed); diff --git a/src/vs/sessions/sessions.web.main.ts b/src/vs/sessions/sessions.web.main.ts index 8054d65f059ff0..b505bd39c07932 100644 --- a/src/vs/sessions/sessions.web.main.ts +++ b/src/vs/sessions/sessions.web.main.ts @@ -71,6 +71,7 @@ import '../platform/extensionResourceLoader/browser/extensionResourceLoaderServi import '../workbench/services/auxiliaryWindow/browser/auxiliaryWindowService.js'; import '../workbench/services/power/browser/powerService.js'; import '../platform/sandbox/browser/sandboxHelperService.js'; +import './services/vscode/browser/themeImporterService.js'; import { InstantiationType, registerSingleton } from '../platform/instantiation/common/extensions.js'; import { IAccessibilityService } from '../platform/accessibility/common/accessibility.js'; From 2e01437459105c20bf5e829420a2f0d4cece1853 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Wed, 6 May 2026 15:34:55 +1000 Subject: [PATCH 26/26] fix: simplify hide action in WorkspacePicker's buildDelegate method (#314646) --- src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index e6d9848aac7fda..0f6528c9ceffd8 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -344,7 +344,7 @@ export class WorkspacePicker extends Disposable { private _buildDelegate(triggerElement: HTMLElement, hide: () => void): IActionListDelegate { return { onSelect: (item) => { - this.actionWidgetService.hide(); + hide(); if (item.commandId) { this.commandService.executeCommand(item.commandId); } else if (item.selection && this._isProviderUnavailable(item.selection.providerId)) {