From a9977fd52ed10ef768ed6ce50f3301e2fa462e6d Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Tue, 5 May 2026 09:18:10 -0700 Subject: [PATCH 01/34] dom: batch DisposableResizeObserver via rAF + convert raw call sites Wrap the user callback of DisposableResizeObserver with scheduleAtNextAnimationFrame so layout writes done by the callback (scanDomNode, KaTeX layout, height assignments, ...) land in the next layout pass instead of re-entering the current one. Also coalesce multiple deliveries within a frame into a single callback invocation. This fixes the root cause of the "ResizeObserver loop completed with undelivered notifications" warning that has been the top error bucket since v1.106 (#293359 - 54M hits / 2.5M users). The warning is benign on its own (Chromium just defers the leftover entries to the next frame), but every occurrence costs an extra layout pass per frame on the affected element. With chat streaming UI (thinking, tool confirmations, plan review, KaTeX) all observing simultaneously, this adds up to several extra layout passes per streamed token on slow machines. Also accept an optional targetWindow so observers created for elements in an auxiliary window use that window's ResizeObserver constructor and animation-frame timer. Convert the three remaining raw `new ResizeObserver(...)` sites to use the wrapper so they inherit the batching + disposable lifetime: - chatMarkdownContentPart (KaTeX layout participants) - chatTerminalToolProgressPart (terminal output resize) - browserEditorFindFeature (find widget height tracking - now passes getWindow(container) to preserve aux-window correctness) Refs #293359 --- src/vs/base/browser/dom.ts | 47 +++++++++++++++++-- .../features/browserEditorFindFeature.ts | 9 ++-- .../chatMarkdownContentPart.ts | 5 +- .../chatTerminalToolProgressPart.ts | 5 +- 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index fe9d624543a86..db624b0b7bf94 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -2049,15 +2049,56 @@ export class DragAndDropObserver extends Disposable { /** * A wrapper around ResizeObserver that is disposable. + * + * The user-supplied callback is invoked at most once per animation frame and + * is scheduled via {@link scheduleAtNextAnimationFrame} rather than running + * synchronously inside the browser's resize-observation phase. Coalescing + * multiple `ResizeObserver` deliveries into a single call also avoids the + * benign-but-noisy "ResizeObserver loop completed with undelivered + * notifications" warning that Chromium emits whenever a callback writes to + * layout (e.g. `scanDomNode()`, setting an element's height, KaTeX layout) + * while the browser is still inside the resize-observation phase. Layout + * writes performed by the callback now land in the next layout pass instead + * of re-entering the current one. + * + * @param callback Invoked with the coalesced list of entries, on the next + * animation frame after the browser delivered them. + * @param targetWindow The window whose `ResizeObserver` constructor and + * animation-frame timer should be used. Defaults to `mainWindow`. Pass the + * containing window when creating an observer for elements that live in an + * auxiliary window. */ export class DisposableResizeObserver extends Disposable { private readonly observer: ResizeObserver; + private pendingEntries: ResizeObserverEntry[] | undefined; + private scheduled: IDisposable | undefined; - constructor(callback: ResizeObserverCallback) { + constructor(callback: ResizeObserverCallback, targetWindow: CodeWindow = mainWindow) { super(); - this.observer = new ResizeObserver(callback); - this._register(toDisposable(() => this.observer.disconnect())); + this.observer = new targetWindow.ResizeObserver((entries: ResizeObserverEntry[]) => { + if (this.pendingEntries) { + this.pendingEntries.push(...entries); + return; + } + this.pendingEntries = entries.slice(); + this.scheduled = scheduleAtNextAnimationFrame(targetWindow, () => { + const batch = this.pendingEntries!; + this.pendingEntries = undefined; + this.scheduled = undefined; + try { + callback(batch, this.observer); + } catch (e) { + onUnexpectedError(e); + } + }); + }); + this._register(toDisposable(() => { + this.scheduled?.dispose(); + this.scheduled = undefined; + this.pendingEntries = undefined; + this.observer.disconnect(); + })); } observe(target: Element, options?: ResizeObserverOptions): IDisposable { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts index 241f78d087c4a..d08c141873177 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { localize, localize2 } from '../../../../../nls.js'; -import { $, getWindow } from '../../../../../base/browser/dom.js'; +import { $, DisposableResizeObserver, getWindow } from '../../../../../base/browser/dom.js'; import { IContextKey, IContextKeyService, ContextKeyExpr, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; import { Action2, registerAction2, MenuId } from '../../../../../platform/actions/common/actions.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -71,15 +71,14 @@ class BrowserFindWidget extends SimpleFindWidget { container.appendChild(domNode); let lastHeight = domNode.offsetHeight; - const resizeObserver = new (getWindow(container).ResizeObserver)(() => { + const resizeObserver = this._register(new DisposableResizeObserver(() => { const newHeight = domNode.offsetHeight; if (newHeight !== lastHeight) { lastHeight = newHeight; this._onDidChangeHeight.fire(); } - }); - resizeObserver.observe(domNode); - this._register(toDisposable(() => resizeObserver.disconnect())); + }, getWindow(container))); + this._register(resizeObserver.observe(domNode)); } /** diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts index a0e4141bf706c..7a8d51e77041f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts @@ -344,9 +344,8 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP store.add(markdownDecorationsRenderer.walkTreeAndAnnotateReferenceLinks(this.markdown, result.element)); const layoutParticipants = new Lazy(() => { - const observer = new ResizeObserver(() => this.mathLayoutParticipants.forEach(layout => layout())); - observer.observe(this.domNode); - store.add(toDisposable(() => observer.disconnect())); + const observer = store.add(new dom.DisposableResizeObserver(() => this.mathLayoutParticipants.forEach(layout => layout()))); + store.add(observer.observe(this.domNode)); return this.mathLayoutParticipants; }); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index f44c85432e633..e7a5e0469dd12 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -1222,9 +1222,8 @@ class ChatTerminalToolOutputSection extends Disposable { this._register(dom.addDisposableListener(this.domNode, dom.EventType.FOCUS_IN, () => this._onDidFocusEmitter.fire())); this._register(dom.addDisposableListener(this.domNode, dom.EventType.FOCUS_OUT, event => this._onDidBlurEmitter.fire(event))); - const resizeObserver = new ResizeObserver(() => this._handleResize()); - resizeObserver.observe(this.domNode); - this._register(toDisposable(() => resizeObserver.disconnect())); + const resizeObserver = this._register(new dom.DisposableResizeObserver(() => this._handleResize())); + this._register(resizeObserver.observe(this.domNode)); this._applyBackgroundColor(); this._register(this._themeService.onDidColorThemeChange(() => this._applyBackgroundColor())); From 4b39c53a2120a3151bf025b4ede35e06ca67a075 Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Tue, 5 May 2026 09:36:14 -0700 Subject: [PATCH 02/34] test: add DisposableResizeObserver batching/dispose tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot review feedback on #314411 — covers rAF deferral, per-frame coalescing, cancel-on-dispose, and reschedule-after-frame. --- src/vs/base/test/browser/dom.test.ts | 103 ++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/src/vs/base/test/browser/dom.test.ts b/src/vs/base/test/browser/dom.test.ts index bf78a2afb13ab..2ccb46fba9641 100644 --- a/src/vs/base/test/browser/dom.test.ts +++ b/src/vs/base/test/browser/dom.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { $, h, trackAttributes, copyAttributes, disposableWindowInterval, getWindows, getWindowsCount, getWindowId, getWindowById, hasWindow, getWindow, getDocument, isHTMLElement, SafeTriangle, AnimationFrameScheduler } from '../../browser/dom.js'; +import { $, h, trackAttributes, copyAttributes, disposableWindowInterval, getWindows, getWindowsCount, getWindowId, getWindowById, hasWindow, getWindow, getDocument, isHTMLElement, SafeTriangle, AnimationFrameScheduler, DisposableResizeObserver } from '../../browser/dom.js'; import { asCssValueWithDefault } from '../../../base/browser/cssValue.js'; import { ensureCodeWindow, isAuxiliaryWindow, mainWindow } from '../../browser/window.js'; import { DeferredPromise, timeout } from '../../common/async.js'; @@ -532,5 +532,106 @@ suite('dom', () => { }); }); + suite('DisposableResizeObserver', () => { + // Helper to wait for an animation frame + const waitForAnimationFrame = () => new Promise(resolve => mainWindow.requestAnimationFrame(() => resolve())); + + // Stub mainWindow.ResizeObserver so we can fire deliveries synthetically + // without depending on real layout. Returns a restore function. + function stubResizeObserver(): { fire: (entries: ResizeObserverEntry[]) => void; restore: () => void; disconnects: number } { + const original = mainWindow.ResizeObserver; + let captured: ResizeObserverCallback | undefined; + const state = { + disconnects: 0, + fire(entries: ResizeObserverEntry[]) { + captured!(entries, {} as ResizeObserver); + }, + restore() { + (mainWindow as any).ResizeObserver = original; + } + }; + class FakeRO { + constructor(cb: ResizeObserverCallback) { captured = cb; } + observe() { /* no-op */ } + unobserve() { /* no-op */ } + disconnect() { state.disconnects++; } + } + (mainWindow as any).ResizeObserver = FakeRO; + return state; + } + + const fakeEntry = (): ResizeObserverEntry => ({} as ResizeObserverEntry); + + test('defers callback to next animation frame (does not invoke synchronously)', async () => { + const stub = stubResizeObserver(); + try { + let calls = 0; + const observer = new DisposableResizeObserver(() => { calls++; }); + stub.fire([fakeEntry()]); + assert.strictEqual(calls, 0, 'callback must not run inside the resize-observation phase'); + await waitForAnimationFrame(); + assert.strictEqual(calls, 1); + observer.dispose(); + } finally { + stub.restore(); + } + }); + + test('coalesces multiple deliveries within one frame into a single callback', async () => { + const stub = stubResizeObserver(); + try { + let calls = 0; + let received: ResizeObserverEntry[] = []; + const observer = new DisposableResizeObserver(entries => { + calls++; + received = entries; + }); + const a = fakeEntry(); + const b = fakeEntry(); + const c = fakeEntry(); + stub.fire([a, b]); + stub.fire([c]); + await waitForAnimationFrame(); + assert.strictEqual(calls, 1, 'multiple deliveries within one frame must coalesce'); + assert.deepStrictEqual(received, [a, b, c], 'entries must be merged in delivery order'); + observer.dispose(); + } finally { + stub.restore(); + } + }); + + test('dispose cancels pending callback and disconnects observer', async () => { + const stub = stubResizeObserver(); + try { + let calls = 0; + const observer = new DisposableResizeObserver(() => { calls++; }); + stub.fire([fakeEntry()]); + observer.dispose(); + await waitForAnimationFrame(); + assert.strictEqual(calls, 0, 'pending callback must not fire after dispose'); + assert.strictEqual(stub.disconnects, 1, 'underlying ResizeObserver must be disconnected'); + } finally { + stub.restore(); + } + }); + + test('reschedules after a frame fires (subsequent deliveries are not lost)', async () => { + const stub = stubResizeObserver(); + try { + let calls = 0; + const observer = new DisposableResizeObserver(() => { calls++; }); + stub.fire([fakeEntry()]); + await waitForAnimationFrame(); + assert.strictEqual(calls, 1); + stub.fire([fakeEntry()]); + await waitForAnimationFrame(); + assert.strictEqual(calls, 2); + observer.dispose(); + } finally { + stub.restore(); + } + }); + }); + ensureNoDisposablesAreLeakedInTestSuite(); }); From e71769c46ba041f64088b56fd681e1d34de9496e Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Tue, 5 May 2026 11:12:52 -0700 Subject: [PATCH 03/34] test: inject ResizeObserver ctor instead of mutating mainWindow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses @mjbvz review on #314411 — drops the global mutation and any-casts in the DisposableResizeObserver test suite by adding an optional resizeObserverCtor parameter (DI). Also addresses Copilot's coalescing finding: per-target latest-wins so consumers reading entries[0] always see the freshest size. --- src/vs/base/browser/dom.ts | 48 +++++--- src/vs/base/test/browser/dom.test.ts | 168 ++++++++++++++------------- 2 files changed, 119 insertions(+), 97 deletions(-) diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index db624b0b7bf94..f91335882c58e 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -2062,41 +2062,51 @@ export class DragAndDropObserver extends Disposable { * of re-entering the current one. * * @param callback Invoked with the coalesced list of entries, on the next - * animation frame after the browser delivered them. + * animation frame after the browser delivered them. If the same target + * resizes more than once before the frame fires, only the most recent entry + * for that target is kept (latest-wins) so consumers reading `entries[0]` + * always see the freshest size. * @param targetWindow The window whose `ResizeObserver` constructor and * animation-frame timer should be used. Defaults to `mainWindow`. Pass the * containing window when creating an observer for elements that live in an * auxiliary window. + * @param resizeObserverCtor Optional `ResizeObserver` constructor override + * for tests. Defaults to `targetWindow.ResizeObserver`. */ export class DisposableResizeObserver extends Disposable { private readonly observer: ResizeObserver; - private pendingEntries: ResizeObserverEntry[] | undefined; + private pendingByTarget: Map | undefined; private scheduled: IDisposable | undefined; - constructor(callback: ResizeObserverCallback, targetWindow: CodeWindow = mainWindow) { + constructor( + callback: ResizeObserverCallback, + targetWindow: CodeWindow = mainWindow, + resizeObserverCtor: typeof ResizeObserver = targetWindow.ResizeObserver, + ) { super(); - this.observer = new targetWindow.ResizeObserver((entries: ResizeObserverEntry[]) => { - if (this.pendingEntries) { - this.pendingEntries.push(...entries); - return; + this.observer = new resizeObserverCtor((entries: ResizeObserverEntry[]) => { + if (!this.pendingByTarget) { + this.pendingByTarget = new Map(); + this.scheduled = scheduleAtNextAnimationFrame(targetWindow, () => { + const batch = Array.from(this.pendingByTarget!.values()); + this.pendingByTarget = undefined; + this.scheduled = undefined; + try { + callback(batch, this.observer); + } catch (e) { + onUnexpectedError(e); + } + }); + } + for (const entry of entries) { + this.pendingByTarget.set(entry.target, entry); } - this.pendingEntries = entries.slice(); - this.scheduled = scheduleAtNextAnimationFrame(targetWindow, () => { - const batch = this.pendingEntries!; - this.pendingEntries = undefined; - this.scheduled = undefined; - try { - callback(batch, this.observer); - } catch (e) { - onUnexpectedError(e); - } - }); }); this._register(toDisposable(() => { this.scheduled?.dispose(); this.scheduled = undefined; - this.pendingEntries = undefined; + this.pendingByTarget = undefined; this.observer.disconnect(); })); } diff --git a/src/vs/base/test/browser/dom.test.ts b/src/vs/base/test/browser/dom.test.ts index 2ccb46fba9641..3c3520513cdb0 100644 --- a/src/vs/base/test/browser/dom.test.ts +++ b/src/vs/base/test/browser/dom.test.ts @@ -536,100 +536,112 @@ suite('dom', () => { // Helper to wait for an animation frame const waitForAnimationFrame = () => new Promise(resolve => mainWindow.requestAnimationFrame(() => resolve())); - // Stub mainWindow.ResizeObserver so we can fire deliveries synthetically - // without depending on real layout. Returns a restore function. - function stubResizeObserver(): { fire: (entries: ResizeObserverEntry[]) => void; restore: () => void; disconnects: number } { - const original = mainWindow.ResizeObserver; - let captured: ResizeObserverCallback | undefined; - const state = { + // Captures the callback handed to a `ResizeObserver` so tests can fire + // deliveries synthetically. Returned via dependency injection — no + // global mutation, no `any` casts. + interface FakeResizeObserverHandle { + ctor: typeof ResizeObserver; + fire: (entries: ResizeObserverEntry[]) => void; + disconnects: number; + } + + function createFakeResizeObserverCtor(): FakeResizeObserverHandle { + const handle: FakeResizeObserverHandle = { + ctor: undefined!, + fire: () => { throw new Error('observer not constructed'); }, disconnects: 0, - fire(entries: ResizeObserverEntry[]) { - captured!(entries, {} as ResizeObserver); - }, - restore() { - (mainWindow as any).ResizeObserver = original; - } }; - class FakeRO { - constructor(cb: ResizeObserverCallback) { captured = cb; } - observe() { /* no-op */ } - unobserve() { /* no-op */ } - disconnect() { state.disconnects++; } + class FakeResizeObserver implements ResizeObserver { + constructor(callback: ResizeObserverCallback) { + handle.fire = entries => callback(entries, this); + } + observe(_target: Element, _options?: ResizeObserverOptions): void { /* no-op */ } + unobserve(_target: Element): void { /* no-op */ } + disconnect(): void { handle.disconnects++; } } - (mainWindow as any).ResizeObserver = FakeRO; - return state; + handle.ctor = FakeResizeObserver; + return handle; } - const fakeEntry = (): ResizeObserverEntry => ({} as ResizeObserverEntry); + function fakeEntry(target: Element = document.createElement('div')): ResizeObserverEntry { + const size: ResizeObserverSize = { blockSize: 0, inlineSize: 0 }; + return { + target, + contentRect: target.getBoundingClientRect(), + borderBoxSize: [size], + contentBoxSize: [size], + devicePixelContentBoxSize: [size], + }; + } test('defers callback to next animation frame (does not invoke synchronously)', async () => { - const stub = stubResizeObserver(); - try { - let calls = 0; - const observer = new DisposableResizeObserver(() => { calls++; }); - stub.fire([fakeEntry()]); - assert.strictEqual(calls, 0, 'callback must not run inside the resize-observation phase'); - await waitForAnimationFrame(); - assert.strictEqual(calls, 1); - observer.dispose(); - } finally { - stub.restore(); - } + const fake = createFakeResizeObserverCtor(); + let calls = 0; + const observer = new DisposableResizeObserver(() => { calls++; }, mainWindow, fake.ctor); + fake.fire([fakeEntry()]); + assert.strictEqual(calls, 0, 'callback must not run inside the resize-observation phase'); + await waitForAnimationFrame(); + assert.strictEqual(calls, 1); + observer.dispose(); }); test('coalesces multiple deliveries within one frame into a single callback', async () => { - const stub = stubResizeObserver(); - try { - let calls = 0; - let received: ResizeObserverEntry[] = []; - const observer = new DisposableResizeObserver(entries => { - calls++; - received = entries; - }); - const a = fakeEntry(); - const b = fakeEntry(); - const c = fakeEntry(); - stub.fire([a, b]); - stub.fire([c]); - await waitForAnimationFrame(); - assert.strictEqual(calls, 1, 'multiple deliveries within one frame must coalesce'); - assert.deepStrictEqual(received, [a, b, c], 'entries must be merged in delivery order'); - observer.dispose(); - } finally { - stub.restore(); - } + const fake = createFakeResizeObserverCtor(); + let calls = 0; + let received: ResizeObserverEntry[] = []; + const observer = new DisposableResizeObserver(entries => { + calls++; + received = entries; + }, mainWindow, fake.ctor); + const a = fakeEntry(); + const b = fakeEntry(); + const c = fakeEntry(); + fake.fire([a, b]); + fake.fire([c]); + await waitForAnimationFrame(); + assert.strictEqual(calls, 1, 'multiple deliveries within one frame must coalesce'); + assert.strictEqual(received.length, 3, 'one entry per distinct target'); + assert.deepStrictEqual(new Set(received), new Set([a, b, c])); + observer.dispose(); + }); + + test('latest entry per target wins when the same target resizes twice in one frame', async () => { + const fake = createFakeResizeObserverCtor(); + let received: ResizeObserverEntry[] = []; + const observer = new DisposableResizeObserver(entries => { received = entries; }, mainWindow, fake.ctor); + const target = document.createElement('div'); + const stale = fakeEntry(target); + const fresh = fakeEntry(target); + fake.fire([stale]); + fake.fire([fresh]); + await waitForAnimationFrame(); + assert.strictEqual(received.length, 1, 'duplicate target must collapse to one entry'); + assert.strictEqual(received[0], fresh, 'consumers reading entries[0] must see the freshest size'); + observer.dispose(); }); test('dispose cancels pending callback and disconnects observer', async () => { - const stub = stubResizeObserver(); - try { - let calls = 0; - const observer = new DisposableResizeObserver(() => { calls++; }); - stub.fire([fakeEntry()]); - observer.dispose(); - await waitForAnimationFrame(); - assert.strictEqual(calls, 0, 'pending callback must not fire after dispose'); - assert.strictEqual(stub.disconnects, 1, 'underlying ResizeObserver must be disconnected'); - } finally { - stub.restore(); - } + const fake = createFakeResizeObserverCtor(); + let calls = 0; + const observer = new DisposableResizeObserver(() => { calls++; }, mainWindow, fake.ctor); + fake.fire([fakeEntry()]); + observer.dispose(); + await waitForAnimationFrame(); + assert.strictEqual(calls, 0, 'pending callback must not fire after dispose'); + assert.strictEqual(fake.disconnects, 1, 'underlying ResizeObserver must be disconnected'); }); test('reschedules after a frame fires (subsequent deliveries are not lost)', async () => { - const stub = stubResizeObserver(); - try { - let calls = 0; - const observer = new DisposableResizeObserver(() => { calls++; }); - stub.fire([fakeEntry()]); - await waitForAnimationFrame(); - assert.strictEqual(calls, 1); - stub.fire([fakeEntry()]); - await waitForAnimationFrame(); - assert.strictEqual(calls, 2); - observer.dispose(); - } finally { - stub.restore(); - } + const fake = createFakeResizeObserverCtor(); + let calls = 0; + const observer = new DisposableResizeObserver(() => { calls++; }, mainWindow, fake.ctor); + fake.fire([fakeEntry()]); + await waitForAnimationFrame(); + assert.strictEqual(calls, 1); + fake.fire([fakeEntry()]); + await waitForAnimationFrame(); + assert.strictEqual(calls, 2); + observer.dispose(); }); }); From 1995a3e5c27585869c07e01993f12ed7c5c10984 Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Tue, 5 May 2026 11:29:58 -0700 Subject: [PATCH 04/34] docs: capture DI-over-global-stub testing learning in copilot-instructions Records the rule that arose from review on #314411: don't mutate globals or use any-casts to install fakes in tests; inject the dependency via an optional constructor parameter. --- .github/copilot-instructions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8157477868544..34e81e2c48078 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -150,3 +150,4 @@ function f(x: number, y: string): void { } ## Learnings - Minimize the amount of assertions in tests. Prefer one snapshot-style `assert.deepStrictEqual` over multiple precise assertions, as they are much more difficult to understand and to update. +- Do not stub a global object (e.g. `(mainWindow as any).ResizeObserver = ...`) or use `any` casts to install fakes in tests. Instead, make the dependency injectable: add an optional constructor parameter on the production class that defaults to the real implementation (e.g. `targetWindow.ResizeObserver`), and have the test pass a fake that implements the real interface. From 1f2793312eb47da759569f16551e2add3b7af287 Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Tue, 5 May 2026 14:44:54 -0700 Subject: [PATCH 05/34] dom: revert DisposableResizeObserver to sync, add loop-warning attribution Reverts the rAF deferral and per-frame coalescing introduced earlier in this PR. Behavior is now identical to the pre-PR sync wrapper, plus: - targetWindow parameter (preserved) for aux-window correctness. - try/catch around the user callback so a single bad consumer cannot kill delivery for every other observer in the realm. - name + creationStack capture, plus a one-shot window 'error' listener that prints which DisposableResizeObservers' callbacks ran in the same task whenever the 'ResizeObserver loop completed with undelivered notifications' warning fires. The browser dispatches that warning as an ErrorEvent (not a thrown exception, error is null), so a try/catch around the callback cannot capture it; we use addEventListener('error', ...). - resizeObserverCtor option for tests (DI seam, replaces global mutation in the test suite). --- src/vs/base/browser/dom.ts | 135 +++++++++++++++++---------- src/vs/base/test/browser/dom.test.ts | 84 +++++++---------- 2 files changed, 121 insertions(+), 98 deletions(-) diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index f91335882c58e..2bbf82335814c 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -2048,67 +2048,61 @@ export class DragAndDropObserver extends Disposable { } /** - * A wrapper around ResizeObserver that is disposable. + * A wrapper around `ResizeObserver` that is disposable. * - * The user-supplied callback is invoked at most once per animation frame and - * is scheduled via {@link scheduleAtNextAnimationFrame} rather than running - * synchronously inside the browser's resize-observation phase. Coalescing - * multiple `ResizeObserver` deliveries into a single call also avoids the - * benign-but-noisy "ResizeObserver loop completed with undelivered - * notifications" warning that Chromium emits whenever a callback writes to - * layout (e.g. `scanDomNode()`, setting an element's height, KaTeX layout) - * while the browser is still inside the resize-observation phase. Layout - * writes performed by the callback now land in the next layout pass instead - * of re-entering the current one. + * Behavior is intentionally identical to using `new ResizeObserver(callback)` + * directly: the user-supplied callback runs synchronously inside the + * browser's resize-observation phase, with the entries the browser delivered. + * The wrapper adds three things on top: * - * @param callback Invoked with the coalesced list of entries, on the next - * animation frame after the browser delivered them. If the same target - * resizes more than once before the frame fires, only the most recent entry - * for that target is kept (latest-wins) so consumers reading `entries[0]` - * always see the freshest size. - * @param targetWindow The window whose `ResizeObserver` constructor and - * animation-frame timer should be used. Defaults to `mainWindow`. Pass the - * containing window when creating an observer for elements that live in an - * auxiliary window. - * @param resizeObserverCtor Optional `ResizeObserver` constructor override - * for tests. Defaults to `targetWindow.ResizeObserver`. + * 1. Lifetime management: `dispose()` disconnects the underlying observer. + * 2. Auxiliary-window support: pass `targetWindow` so the observer is + * constructed in the realm of the element being observed. + * 3. Attribution for the + * `ResizeObserver loop completed with undelivered notifications` warning: + * each instance captures its construction stack (and an optional `name`), + * and the first time the warning fires in `targetWindow` we log every + * observer whose callback ran in the same task. This makes it possible to + * pinpoint the offending consumer instead of seeing only the top-level + * warning. The warning is delivered as an `ErrorEvent` on `window` (with a + * `null` `error`), not a thrown exception, so a try/catch around the + * callback cannot capture it; we use `addEventListener('error', ...)`. + * + * @param callback Invoked synchronously when the browser delivers resize + * notifications, with the same entries the native `ResizeObserver` would + * have delivered. + * @param targetWindow The window whose `ResizeObserver` constructor should + * be used. Defaults to `mainWindow`. Pass the containing window when + * creating an observer for elements that live in an auxiliary window. + * @param options Optional configuration. `name` is used in attribution + * logs; `resizeObserverCtor` is a test seam that defaults to + * `targetWindow.ResizeObserver`. */ export class DisposableResizeObserver extends Disposable { private readonly observer: ResizeObserver; - private pendingByTarget: Map | undefined; - private scheduled: IDisposable | undefined; + readonly name: string | undefined; + readonly creationStack: string; constructor( callback: ResizeObserverCallback, targetWindow: CodeWindow = mainWindow, - resizeObserverCtor: typeof ResizeObserver = targetWindow.ResizeObserver, + options?: { name?: string; resizeObserverCtor?: typeof ResizeObserver }, ) { super(); - this.observer = new resizeObserverCtor((entries: ResizeObserverEntry[]) => { - if (!this.pendingByTarget) { - this.pendingByTarget = new Map(); - this.scheduled = scheduleAtNextAnimationFrame(targetWindow, () => { - const batch = Array.from(this.pendingByTarget!.values()); - this.pendingByTarget = undefined; - this.scheduled = undefined; - try { - callback(batch, this.observer); - } catch (e) { - onUnexpectedError(e); - } - }); - } - for (const entry of entries) { - this.pendingByTarget.set(entry.target, entry); + this.name = options?.name; + this.creationStack = new Error().stack ?? ''; + installResizeObserverLoopAttribution(targetWindow); + const ctor = options?.resizeObserverCtor ?? targetWindow.ResizeObserver; + this.observer = new ctor((entries: ResizeObserverEntry[], observer) => { + recordResizeObserverCallbackInvocation(this); + try { + callback(entries, observer); + } catch (e) { + onUnexpectedError(e); } }); - this._register(toDisposable(() => { - this.scheduled?.dispose(); - this.scheduled = undefined; - this.pendingByTarget = undefined; - this.observer.disconnect(); - })); + this._register(toDisposable(() => this.observer.disconnect())); } observe(target: Element, options?: ResizeObserverOptions): IDisposable { @@ -2117,6 +2111,53 @@ export class DisposableResizeObserver extends Disposable { } } +/** + * Set of `DisposableResizeObserver`s whose callbacks ran since the last time + * the `ResizeObserver loop completed with undelivered notifications` warning + * fired. The warning is dispatched synchronously after all callbacks for a + * frame have run, so this set still contains the suspects when our error + * listener inspects it. Cleared on every warning and after each animation + * frame to bound memory. + */ +const _firedSinceLastResizeWarning = new Set(); +let _firedFlushScheduled = false; +const _attributionInstalledWindows = new WeakSet(); + +function recordResizeObserverCallbackInvocation(observer: DisposableResizeObserver): void { + _firedSinceLastResizeWarning.add(observer); + if (!_firedFlushScheduled) { + _firedFlushScheduled = true; + mainWindow.requestAnimationFrame(() => { + _firedFlushScheduled = false; + _firedSinceLastResizeWarning.clear(); + }); + } +} + +function installResizeObserverLoopAttribution(targetWindow: CodeWindow): void { + if (_attributionInstalledWindows.has(targetWindow)) { + return; + } + _attributionInstalledWindows.add(targetWindow); + targetWindow.addEventListener('error', (e: ErrorEvent) => { + if (typeof e.message !== 'string' || !e.message.includes('ResizeObserver loop')) { + return; + } + if (_firedSinceLastResizeWarning.size === 0) { + return; + } + const suspects = Array.from(_firedSinceLastResizeWarning, o => ({ + name: o.name, + creationStack: o.creationStack, + })); + _firedSinceLastResizeWarning.clear(); + // Use console.warn so it's visible in DevTools alongside the original + // browser warning but does not surface as an unexpected error in + // telemetry. + console.warn('[DisposableResizeObserver] ResizeObserver loop fired. Suspect callbacks (ran in same task):', suspects); + }); +} + type HTMLElementAttributeKeys = Partial<{ [K in keyof T]: T[K] extends Function ? never : T[K] extends object ? HTMLElementAttributeKeys : T[K] }>; type ElementAttributes = HTMLElementAttributeKeys & Record; type RemoveHTMLElement = T extends HTMLElement ? never : T; diff --git a/src/vs/base/test/browser/dom.test.ts b/src/vs/base/test/browser/dom.test.ts index 3c3520513cdb0..9ccb8628f18b5 100644 --- a/src/vs/base/test/browser/dom.test.ts +++ b/src/vs/base/test/browser/dom.test.ts @@ -533,9 +533,6 @@ suite('dom', () => { }); suite('DisposableResizeObserver', () => { - // Helper to wait for an animation frame - const waitForAnimationFrame = () => new Promise(resolve => mainWindow.requestAnimationFrame(() => resolve())); - // Captures the callback handed to a `ResizeObserver` so tests can fire // deliveries synthetically. Returned via dependency injection — no // global mutation, no `any` casts. @@ -574,73 +571,58 @@ suite('dom', () => { }; } - test('defers callback to next animation frame (does not invoke synchronously)', async () => { - const fake = createFakeResizeObserverCtor(); - let calls = 0; - const observer = new DisposableResizeObserver(() => { calls++; }, mainWindow, fake.ctor); - fake.fire([fakeEntry()]); - assert.strictEqual(calls, 0, 'callback must not run inside the resize-observation phase'); - await waitForAnimationFrame(); - assert.strictEqual(calls, 1); - observer.dispose(); - }); - - test('coalesces multiple deliveries within one frame into a single callback', async () => { + test('callback runs synchronously with the entries the browser delivered', () => { const fake = createFakeResizeObserverCtor(); let calls = 0; - let received: ResizeObserverEntry[] = []; - const observer = new DisposableResizeObserver(entries => { + let received: ResizeObserverEntry[] | undefined; + const observer = new DisposableResizeObserver((entries) => { calls++; received = entries; - }, mainWindow, fake.ctor); + }, mainWindow, { resizeObserverCtor: fake.ctor }); const a = fakeEntry(); const b = fakeEntry(); - const c = fakeEntry(); fake.fire([a, b]); - fake.fire([c]); - await waitForAnimationFrame(); - assert.strictEqual(calls, 1, 'multiple deliveries within one frame must coalesce'); - assert.strictEqual(received.length, 3, 'one entry per distinct target'); - assert.deepStrictEqual(new Set(received), new Set([a, b, c])); + assert.strictEqual(calls, 1, 'callback runs synchronously inside the resize-observation phase'); + assert.deepStrictEqual(received, [a, b], 'entries are forwarded as-is'); observer.dispose(); }); - test('latest entry per target wins when the same target resizes twice in one frame', async () => { + test('each native delivery invokes the callback once (no batching)', () => { const fake = createFakeResizeObserverCtor(); - let received: ResizeObserverEntry[] = []; - const observer = new DisposableResizeObserver(entries => { received = entries; }, mainWindow, fake.ctor); - const target = document.createElement('div'); - const stale = fakeEntry(target); - const fresh = fakeEntry(target); - fake.fire([stale]); - fake.fire([fresh]); - await waitForAnimationFrame(); - assert.strictEqual(received.length, 1, 'duplicate target must collapse to one entry'); - assert.strictEqual(received[0], fresh, 'consumers reading entries[0] must see the freshest size'); + let calls = 0; + const observer = new DisposableResizeObserver(() => { calls++; }, mainWindow, { resizeObserverCtor: fake.ctor }); + fake.fire([fakeEntry()]); + fake.fire([fakeEntry()]); + assert.strictEqual(calls, 2, 'wrapper does not coalesce deliveries'); observer.dispose(); }); - test('dispose cancels pending callback and disconnects observer', async () => { + test('dispose disconnects the underlying observer', () => { const fake = createFakeResizeObserverCtor(); - let calls = 0; - const observer = new DisposableResizeObserver(() => { calls++; }, mainWindow, fake.ctor); - fake.fire([fakeEntry()]); + const observer = new DisposableResizeObserver(() => { /* noop */ }, mainWindow, { resizeObserverCtor: fake.ctor }); observer.dispose(); - await waitForAnimationFrame(); - assert.strictEqual(calls, 0, 'pending callback must not fire after dispose'); - assert.strictEqual(fake.disconnects, 1, 'underlying ResizeObserver must be disconnected'); + assert.strictEqual(fake.disconnects, 1); }); - test('reschedules after a frame fires (subsequent deliveries are not lost)', async () => { + test('exceptions in the user callback do not propagate', () => { const fake = createFakeResizeObserverCtor(); - let calls = 0; - const observer = new DisposableResizeObserver(() => { calls++; }, mainWindow, fake.ctor); - fake.fire([fakeEntry()]); - await waitForAnimationFrame(); - assert.strictEqual(calls, 1); - fake.fire([fakeEntry()]); - await waitForAnimationFrame(); - assert.strictEqual(calls, 2); + const observer = new DisposableResizeObserver(() => { throw new Error('boom'); }, mainWindow, { resizeObserverCtor: fake.ctor }); + // Browser would not catch a throw out of the native callback; we + // must guard so a single bad consumer does not break delivery for + // every other observer in the realm. + assert.doesNotThrow(() => fake.fire([fakeEntry()])); + observer.dispose(); + }); + + test('captures construction stack and optional name for attribution', () => { + const fake = createFakeResizeObserverCtor(); + const observer = new DisposableResizeObserver( + () => { /* noop */ }, + mainWindow, + { name: 'my-observer', resizeObserverCtor: fake.ctor }, + ); + assert.strictEqual(observer.name, 'my-observer'); + assert.ok(observer.creationStack.length > 0, 'creation stack must be captured'); observer.dispose(); }); }); From 2613d4a8af892eb329b1bbb45c67b811ebbe4484 Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Tue, 5 May 2026 16:00:43 -0700 Subject: [PATCH 06/34] dom: attribute ResizeObserver loop warning to DisposableResizeObserver name Replace the previous suspect-set + window error listener attribution with a simpler design: - DisposableResizeObserver now takes a required `name: string` as its first constructor parameter. Drop the per-instance creation stack capture (callstacks change across releases and bloat telemetry). - Track the most recently invoked observer in a module-level slot and expose `getRecentDisposableResizeObserverAttributionForLoopError(msg)` which, for the stackless `ResizeObserver loop completed with undelivered notifications` warning, returns `[DisposableResizeObserver()] `. - Browser ErrorTelemetry uses the helper to prefix both `msg` and `callstack` so the telemetry bucket carries the offending observer's name instead of just the generic warning. - Name all 17 production sites with `.` (e.g. ChatListItemRenderer.itemHeight, ChatInputPart.containerHeight). Refs #293359. --- src/vs/base/browser/dom.ts | 95 ++++++++----------- src/vs/base/test/browser/dom.test.ts | 37 ++++++-- .../telemetry/browser/errorTelemetry.ts | 11 +++ .../browser/parts/chatCompositeBar.ts | 2 +- .../features/browserEditorFindFeature.ts | 2 +- ...CustomizationWelcomePagePromptLaunchers.ts | 2 +- .../chatConfirmationWidget.ts | 8 +- .../chatMarkdownContentPart.ts | 4 +- .../chatContentParts/chatPlanReviewPart.ts | 2 +- .../chatQuestionCarouselPart.ts | 2 +- .../chatSubagentContentPart.ts | 2 +- .../chatThinkingContentPart.ts | 4 +- .../chatTerminalToolProgressPart.ts | 2 +- .../chatToolConfirmationCarouselPart.ts | 2 +- .../chat/browser/widget/chatListRenderer.ts | 2 +- .../browser/widget/input/chatInputPart.ts | 4 +- 16 files changed, 96 insertions(+), 85 deletions(-) diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index 2bbf82335814c..ca288f06badf9 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -2060,42 +2060,41 @@ export class DragAndDropObserver extends Disposable { * constructed in the realm of the element being observed. * 3. Attribution for the * `ResizeObserver loop completed with undelivered notifications` warning: - * each instance captures its construction stack (and an optional `name`), - * and the first time the warning fires in `targetWindow` we log every - * observer whose callback ran in the same task. This makes it possible to - * pinpoint the offending consumer instead of seeing only the top-level - * warning. The warning is delivered as an `ErrorEvent` on `window` (with a - * `null` `error`), not a thrown exception, so a try/catch around the - * callback cannot capture it; we use `addEventListener('error', ...)`. + * each instance carries a stable `name`, and just before invoking the + * user callback we publish that name to a module-level slot. The warning + * is delivered as a stackless `ErrorEvent` on `window` after callbacks + * run, so error telemetry can swap in the most-recently-invoked + * observer's name to pinpoint the offending consumer (see + * {@link getRecentDisposableResizeObserverAttributionForLoopError}). * + * @param name Stable identifier used in attribution. Prefer something that + * survives minification and refactors (e.g. the consumer class + purpose) + * since callstacks change across releases. * @param callback Invoked synchronously when the browser delivers resize * notifications, with the same entries the native `ResizeObserver` would * have delivered. * @param targetWindow The window whose `ResizeObserver` constructor should * be used. Defaults to `mainWindow`. Pass the containing window when * creating an observer for elements that live in an auxiliary window. - * @param options Optional configuration. `name` is used in attribution - * logs; `resizeObserverCtor` is a test seam that defaults to - * `targetWindow.ResizeObserver`. + * @param options Optional configuration. `resizeObserverCtor` is a test + * seam that defaults to `targetWindow.ResizeObserver`. */ export class DisposableResizeObserver extends Disposable { private readonly observer: ResizeObserver; - readonly name: string | undefined; - readonly creationStack: string; + readonly name: string; constructor( + name: string, callback: ResizeObserverCallback, targetWindow: CodeWindow = mainWindow, - options?: { name?: string; resizeObserverCtor?: typeof ResizeObserver }, + options?: { resizeObserverCtor?: typeof ResizeObserver }, ) { super(); - this.name = options?.name; - this.creationStack = new Error().stack ?? ''; - installResizeObserverLoopAttribution(targetWindow); + this.name = name; const ctor = options?.resizeObserverCtor ?? targetWindow.ResizeObserver; this.observer = new ctor((entries: ResizeObserverEntry[], observer) => { - recordResizeObserverCallbackInvocation(this); + _lastInvokedDisposableResizeObserver = this; try { callback(entries, observer); } catch (e) { @@ -2112,50 +2111,30 @@ export class DisposableResizeObserver extends Disposable { } /** - * Set of `DisposableResizeObserver`s whose callbacks ran since the last time - * the `ResizeObserver loop completed with undelivered notifications` warning - * fired. The warning is dispatched synchronously after all callbacks for a - * frame have run, so this set still contains the suspects when our error - * listener inspects it. Cleared on every warning and after each animation - * frame to bound memory. + * The most recently invoked `DisposableResizeObserver`. Set just before each + * user callback runs and never cleared. The + * `ResizeObserver loop completed with undelivered notifications` warning + * fires as a stackless `ErrorEvent` after callbacks have run, so this slot + * still points at a plausible suspect when telemetry asks about it. */ -const _firedSinceLastResizeWarning = new Set(); -let _firedFlushScheduled = false; -const _attributionInstalledWindows = new WeakSet(); - -function recordResizeObserverCallbackInvocation(observer: DisposableResizeObserver): void { - _firedSinceLastResizeWarning.add(observer); - if (!_firedFlushScheduled) { - _firedFlushScheduled = true; - mainWindow.requestAnimationFrame(() => { - _firedFlushScheduled = false; - _firedSinceLastResizeWarning.clear(); - }); - } -} +let _lastInvokedDisposableResizeObserver: DisposableResizeObserver | undefined; -function installResizeObserverLoopAttribution(targetWindow: CodeWindow): void { - if (_attributionInstalledWindows.has(targetWindow)) { - return; +/** + * If `message` looks like the ResizeObserver loop warning, return an + * attribution string built from the most recently invoked + * `DisposableResizeObserver` so error telemetry can replace its synthetic, + * stackless callstack with the offending observer's `name`. Returns + * `undefined` for unrelated messages or when no observer has fired yet. + */ +export function getRecentDisposableResizeObserverAttributionForLoopError(message: string | undefined | null): string | undefined { + if (typeof message !== 'string' || !message.includes('ResizeObserver loop')) { + return undefined; } - _attributionInstalledWindows.add(targetWindow); - targetWindow.addEventListener('error', (e: ErrorEvent) => { - if (typeof e.message !== 'string' || !e.message.includes('ResizeObserver loop')) { - return; - } - if (_firedSinceLastResizeWarning.size === 0) { - return; - } - const suspects = Array.from(_firedSinceLastResizeWarning, o => ({ - name: o.name, - creationStack: o.creationStack, - })); - _firedSinceLastResizeWarning.clear(); - // Use console.warn so it's visible in DevTools alongside the original - // browser warning but does not surface as an unexpected error in - // telemetry. - console.warn('[DisposableResizeObserver] ResizeObserver loop fired. Suspect callbacks (ran in same task):', suspects); - }); + const observer = _lastInvokedDisposableResizeObserver; + if (!observer) { + return undefined; + } + return `[DisposableResizeObserver(${observer.name})] ${message}`; } type HTMLElementAttributeKeys = Partial<{ [K in keyof T]: T[K] extends Function ? never : T[K] extends object ? HTMLElementAttributeKeys : T[K] }>; diff --git a/src/vs/base/test/browser/dom.test.ts b/src/vs/base/test/browser/dom.test.ts index 9ccb8628f18b5..184b958e93ba6 100644 --- a/src/vs/base/test/browser/dom.test.ts +++ b/src/vs/base/test/browser/dom.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { $, h, trackAttributes, copyAttributes, disposableWindowInterval, getWindows, getWindowsCount, getWindowId, getWindowById, hasWindow, getWindow, getDocument, isHTMLElement, SafeTriangle, AnimationFrameScheduler, DisposableResizeObserver } from '../../browser/dom.js'; +import { $, h, trackAttributes, copyAttributes, disposableWindowInterval, getWindows, getWindowsCount, getWindowId, getWindowById, hasWindow, getWindow, getDocument, isHTMLElement, SafeTriangle, AnimationFrameScheduler, DisposableResizeObserver, getRecentDisposableResizeObserverAttributionForLoopError } from '../../browser/dom.js'; import { asCssValueWithDefault } from '../../../base/browser/cssValue.js'; import { ensureCodeWindow, isAuxiliaryWindow, mainWindow } from '../../browser/window.js'; import { DeferredPromise, timeout } from '../../common/async.js'; @@ -575,7 +575,7 @@ suite('dom', () => { const fake = createFakeResizeObserverCtor(); let calls = 0; let received: ResizeObserverEntry[] | undefined; - const observer = new DisposableResizeObserver((entries) => { + const observer = new DisposableResizeObserver('test.sync', (entries) => { calls++; received = entries; }, mainWindow, { resizeObserverCtor: fake.ctor }); @@ -590,7 +590,7 @@ suite('dom', () => { test('each native delivery invokes the callback once (no batching)', () => { const fake = createFakeResizeObserverCtor(); let calls = 0; - const observer = new DisposableResizeObserver(() => { calls++; }, mainWindow, { resizeObserverCtor: fake.ctor }); + const observer = new DisposableResizeObserver('test.noBatch', () => { calls++; }, mainWindow, { resizeObserverCtor: fake.ctor }); fake.fire([fakeEntry()]); fake.fire([fakeEntry()]); assert.strictEqual(calls, 2, 'wrapper does not coalesce deliveries'); @@ -599,14 +599,14 @@ suite('dom', () => { test('dispose disconnects the underlying observer', () => { const fake = createFakeResizeObserverCtor(); - const observer = new DisposableResizeObserver(() => { /* noop */ }, mainWindow, { resizeObserverCtor: fake.ctor }); + const observer = new DisposableResizeObserver('test.dispose', () => { /* noop */ }, mainWindow, { resizeObserverCtor: fake.ctor }); observer.dispose(); assert.strictEqual(fake.disconnects, 1); }); test('exceptions in the user callback do not propagate', () => { const fake = createFakeResizeObserverCtor(); - const observer = new DisposableResizeObserver(() => { throw new Error('boom'); }, mainWindow, { resizeObserverCtor: fake.ctor }); + const observer = new DisposableResizeObserver('test.throw', () => { throw new Error('boom'); }, mainWindow, { resizeObserverCtor: fake.ctor }); // Browser would not catch a throw out of the native callback; we // must guard so a single bad consumer does not break delivery for // every other observer in the realm. @@ -614,17 +614,38 @@ suite('dom', () => { observer.dispose(); }); - test('captures construction stack and optional name for attribution', () => { + test('exposes the configured name for attribution', () => { const fake = createFakeResizeObserverCtor(); const observer = new DisposableResizeObserver( + 'my-observer', () => { /* noop */ }, mainWindow, - { name: 'my-observer', resizeObserverCtor: fake.ctor }, + { resizeObserverCtor: fake.ctor }, ); assert.strictEqual(observer.name, 'my-observer'); - assert.ok(observer.creationStack.length > 0, 'creation stack must be captured'); observer.dispose(); }); + + test('getRecentDisposableResizeObserverAttributionForLoopError returns undefined for unrelated messages', () => { + assert.strictEqual(getRecentDisposableResizeObserverAttributionForLoopError(undefined), undefined); + assert.strictEqual(getRecentDisposableResizeObserverAttributionForLoopError('Uncaught TypeError: foo'), undefined); + }); + + test('getRecentDisposableResizeObserverAttributionForLoopError returns last invoked observer name for the loop warning', () => { + const fake = createFakeResizeObserverCtor(); + const a = new DisposableResizeObserver('a', () => { /* noop */ }, mainWindow, { resizeObserverCtor: fake.ctor }); + fake.fire([fakeEntry()]); + const fakeB = createFakeResizeObserverCtor(); + const b = new DisposableResizeObserver('b', () => { /* noop */ }, mainWindow, { resizeObserverCtor: fakeB.ctor }); + fakeB.fire([fakeEntry()]); + const attribution = getRecentDisposableResizeObserverAttributionForLoopError( + 'ResizeObserver loop completed with undelivered notifications.', + ); + assert.ok(attribution, 'attribution string must be produced for the loop warning'); + assert.ok(attribution!.startsWith('[DisposableResizeObserver(b)] '), 'attribution prefixes the message with the most recently invoked observer name'); + a.dispose(); + b.dispose(); + }); }); ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/platform/telemetry/browser/errorTelemetry.ts b/src/vs/platform/telemetry/browser/errorTelemetry.ts index 964ef588d5d77..b9db60c37a2c6 100644 --- a/src/vs/platform/telemetry/browser/errorTelemetry.ts +++ b/src/vs/platform/telemetry/browser/errorTelemetry.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { mainWindow } from '../../../base/browser/window.js'; +import { getRecentDisposableResizeObserverAttributionForLoopError } from '../../../base/browser/dom.js'; import { ErrorNoTelemetry } from '../../../base/common/errors.js'; import { toDisposable } from '../../../base/common/lifecycle.js'; import BaseErrorTelemetry, { ErrorEvent } from '../common/errorTelemetry.js'; @@ -53,6 +54,16 @@ export default class ErrorTelemetry extends BaseErrorTelemetry { } } + // Attribute the stackless `ResizeObserver loop completed with + // undelivered notifications` warning to the most recently invoked + // `DisposableResizeObserver`, so the bucket points at the offending + // consumer instead of just the message. + const resizeObserverAttribution = getRecentDisposableResizeObserverAttributionForLoopError(msg); + if (resizeObserverAttribution) { + data.msg = resizeObserverAttribution; + data.callstack = resizeObserverAttribution; + } + this._enqueue(data); } } diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index c7df14a32d252..819ca97443f70 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -80,7 +80,7 @@ export class ChatCompositeBar extends Disposable { })); // Scroll active tab into view on resize - const resizeObserver = this._register(new DisposableResizeObserver(() => this._revealActiveTab())); + const resizeObserver = this._register(new DisposableResizeObserver('ChatCompositeBar.activeTabReveal', () => this._revealActiveTab())); this._register(resizeObserver.observe(this._tabsContainer)); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts index d08c141873177..89a17a398acaf 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts @@ -71,7 +71,7 @@ class BrowserFindWidget extends SimpleFindWidget { container.appendChild(domNode); let lastHeight = domNode.offsetHeight; - const resizeObserver = this._register(new DisposableResizeObserver(() => { + const resizeObserver = this._register(new DisposableResizeObserver('BrowserEditorFindFeature.heightChange', () => { const newHeight = domNode.offsetHeight; if (newHeight !== lastHeight) { lastHeight = newHeight; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWelcomePagePromptLaunchers.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWelcomePagePromptLaunchers.ts index bf7bfb1909455..e969fe14fd211 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWelcomePagePromptLaunchers.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWelcomePagePromptLaunchers.ts @@ -108,7 +108,7 @@ export class PromptLaunchersAICustomizationWelcomePage extends Disposable implem // Re-scan whenever the wrapper changes size so the scrollbar reflects // the current overflow state. rebuildCards() scans after content changes. - const resizeObserver = this._register(new DOM.DisposableResizeObserver(() => this.scrollable.scanDomNode())); + const resizeObserver = this._register(new DOM.DisposableResizeObserver('AICustomizationWelcomePagePromptLaunchers.scrollable', () => this.scrollable.scanDomNode())); this._register(resizeObserver.observe(scrollableNode)); const welcomeInner = DOM.append(this.container, $('.welcome-prompts-inner')); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationWidget.ts index b992275f26f58..d35cec469ce92 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationWidget.ts @@ -178,7 +178,7 @@ abstract class BaseSimpleChatConfirmationWidget extends Disposable { })); this.messageScrollable.getDomNode().classList.add('chat-confirmation-widget-message-scrollable'); messageParent?.insertBefore(this.messageScrollable.getDomNode(), messageNextSibling); - const messageResizeObserver = this._register(new dom.DisposableResizeObserver(() => this.messageScrollable.scanDomNode())); + const messageResizeObserver = this._register(new dom.DisposableResizeObserver('BaseSimpleChatConfirmationWidget.message', () => this.messageScrollable.scanDomNode())); this._register(messageResizeObserver.observe(this.messageElement)); this._register(messageResizeObserver.observe(this.messageScrollable.getDomNode())); @@ -244,7 +244,7 @@ abstract class BaseSimpleChatConfirmationWidget extends Disposable { protected renderMessage(element: HTMLElement): void { const store = new DisposableStore(); - const messageContentResizeObserver = store.add(new dom.DisposableResizeObserver(() => this.messageScrollable.scanDomNode())); + const messageContentResizeObserver = store.add(new dom.DisposableResizeObserver('BaseSimpleChatConfirmationWidget.messageContent', () => this.messageScrollable.scanDomNode())); store.add(messageContentResizeObserver.observe(element)); this.messageContentDisposables.value = store; this.messageElement.append(element); @@ -363,7 +363,7 @@ abstract class BaseChatConfirmationWidget extends Disposable { })); this.messageScrollable.getDomNode().classList.add('chat-confirmation-widget-message-scrollable'); messageParent?.insertBefore(this.messageScrollable.getDomNode(), messageNextSibling); - const messageResizeObserver = this._register(new dom.DisposableResizeObserver(() => this.messageScrollable.scanDomNode())); + const messageResizeObserver = this._register(new dom.DisposableResizeObserver('BaseChatConfirmationWidget.message', () => this.messageScrollable.scanDomNode())); this._register(messageResizeObserver.observe(this.messageElement)); this._register(messageResizeObserver.observe(this.messageScrollable.getDomNode())); @@ -462,7 +462,7 @@ abstract class BaseChatConfirmationWidget extends Disposable { dom.clearNode(this.messageElement); const store = new DisposableStore(); - const messageContentResizeObserver = store.add(new dom.DisposableResizeObserver(() => this.messageScrollable.scanDomNode())); + const messageContentResizeObserver = store.add(new dom.DisposableResizeObserver('BaseChatConfirmationWidget.messageContent', () => this.messageScrollable.scanDomNode())); store.add(messageContentResizeObserver.observe(element)); if (this.markdownContentPart.value) { store.add(this.markdownContentPart.value.onDidChangeHeight(() => this.messageScrollable.scanDomNode())); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts index 7a8d51e77041f..a4c59c1206808 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts @@ -16,7 +16,7 @@ import { findLast } from '../../../../../../base/common/arraysFind.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; -import { Disposable, DisposableStore, dispose, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, dispose, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { autorun, autorunSelfDisposable, derived } from '../../../../../../base/common/observable.js'; import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; @@ -344,7 +344,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP store.add(markdownDecorationsRenderer.walkTreeAndAnnotateReferenceLinks(this.markdown, result.element)); const layoutParticipants = new Lazy(() => { - const observer = store.add(new dom.DisposableResizeObserver(() => this.mathLayoutParticipants.forEach(layout => layout()))); + const observer = store.add(new dom.DisposableResizeObserver('ChatMarkdownContentPart.mathLayout', () => this.mathLayoutParticipants.forEach(layout => layout()))); store.add(observer.observe(this.domNode)); return this.mathLayoutParticipants; }); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts index f5507d83fb1df..7d027a4b58aac 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts @@ -180,7 +180,7 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { })); this._messageScrollable.getDomNode().classList.add('chat-confirmation-widget-message-scrollable', 'chat-plan-review-body-scrollable'); messageParent.insertBefore(this._messageScrollable.getDomNode(), messageNextSibling); - const resizeObserver = this._register(new dom.DisposableResizeObserver(() => this._messageScrollable.scanDomNode())); + const resizeObserver = this._register(new dom.DisposableResizeObserver('ChatPlanReviewPart.messageScrollable', () => this._messageScrollable.scanDomNode())); // The inner `_messageEl` is `height: 100%`, so observing only the // wrapper is enough; markdown content reflow is handled by the // renderer's `asyncRenderCallback`. diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index 5035fa261c697..206dbe80d6ef3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -814,7 +814,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent }); }; - const inputResizeObserver = questionRenderStore.add(new dom.DisposableResizeObserver(() => scheduleLayoutInputScrollable())); + const inputResizeObserver = questionRenderStore.add(new dom.DisposableResizeObserver('ChatQuestionCarouselPart.inputScrollable', () => scheduleLayoutInputScrollable())); questionRenderStore.add(inputResizeObserver.observe(inputScrollableNode)); questionRenderStore.add(inputResizeObserver.observe(inputContainer)); questionRenderStore.add(dom.addDisposableListener(dom.getWindow(this.domNode), dom.EventType.RESIZE, () => scheduleLayoutInputScrollable())); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts index ed4aceae37447..c6119bd20e1d7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -344,7 +344,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen } // Use ResizeObserver to trigger layout when wrapper content changes - const resizeObserver = this._register(new DisposableResizeObserver(() => this.layoutScheduler.schedule())); + const resizeObserver = this._register(new DisposableResizeObserver('ChatSubagentContentPart.layout', () => this.layoutScheduler.schedule())); this._register(resizeObserver.observe(this.wrapper)); return this.wrapper; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index 8d9b9600988b5..4efdd4469ec26 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -548,7 +548,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen // Observe child elements for resizes (e.g. terminal output growing) // so we can update scroll dimensions when the wrapper box is pinned at max-height. - this.childResizeObserver = this._register(new DisposableResizeObserver(() => { + this.childResizeObserver = this._register(new DisposableResizeObserver('ChatThinkingContentPart.child', () => { if (this.streamingCompleted || !this.domNode.classList.contains('chat-used-context-collapsed')) { return; } @@ -563,7 +563,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } // Cache wrapper scrollHeight post-layout via ResizeObserver to avoid forced reflows. - const wrapperResizeObserver = this._register(new DisposableResizeObserver((entries) => { + const wrapperResizeObserver = this._register(new DisposableResizeObserver('ChatThinkingContentPart.wrapper', (entries) => { if (entries[0]) { this.lastKnownContentHeight = this.wrapper.scrollHeight; if (!this.streamingCompleted && this.domNode.classList.contains('chat-used-context-collapsed')) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index e7a5e0469dd12..ec72ce6ba2113 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -1222,7 +1222,7 @@ class ChatTerminalToolOutputSection extends Disposable { this._register(dom.addDisposableListener(this.domNode, dom.EventType.FOCUS_IN, () => this._onDidFocusEmitter.fire())); this._register(dom.addDisposableListener(this.domNode, dom.EventType.FOCUS_OUT, event => this._onDidBlurEmitter.fire(event))); - const resizeObserver = this._register(new dom.DisposableResizeObserver(() => this._handleResize())); + const resizeObserver = this._register(new dom.DisposableResizeObserver('ChatTerminalToolProgressPart.handleResize', () => this._handleResize())); this._register(resizeObserver.observe(this.domNode)); this._applyBackgroundColor(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationCarouselPart.ts index f07e7f4970f19..e2ea9a13662c2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationCarouselPart.ts @@ -100,7 +100,7 @@ export class ChatToolConfirmationCarouselPart extends Disposable { this.stepIndicator = elements.stepIndicator; this.activeContentDisposables = this._register(new DisposableStore()); this.updateContentExpansionStateScheduler = this._register(new dom.AnimationFrameScheduler(this.domNode, () => this.updateContentExpansionState())); - this.contentResizeObserver = this._register(new dom.DisposableResizeObserver(() => this.updateContentExpansionStateScheduler.schedule())); + this.contentResizeObserver = this._register(new dom.DisposableResizeObserver('ChatToolConfirmationCarouselPart.contentExpansion', () => this.updateContentExpansionStateScheduler.schedule())); this._register(this.contentResizeObserver.observe(this.contentContainer)); this.allowAllButton = this._register(new Button(elements.overlayActions, { ...defaultButtonStyles, small: true })); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 2b57beb4f669f..2c1614d1ddcf9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -636,7 +636,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { + const resizeObserver = templateDisposables.add(new dom.DisposableResizeObserver('ChatListItemRenderer.itemHeight', (entries) => { const entry = entries[0]; if (entry) { this.fireItemHeightChange(template, entry.borderBoxSize.at(0)?.blockSize); 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 3aad8c6c0eb60..ae0cd792e0364 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -2628,7 +2628,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.renderAttachedContext(); - const inputResizeObserver = this._register(new dom.DisposableResizeObserver(() => { + const inputResizeObserver = this._register(new dom.DisposableResizeObserver('ChatInputPart.containerHeight', () => { this.updateToolConfirmationCarouselMaxHeight(); const newHeight = this.container.offsetHeight; this.height.set(newHeight, undefined); @@ -2636,7 +2636,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._register(inputResizeObserver.observe(this.container)); if (this.options.renderStyle === 'compact') { - const toolbarsResizeObserver = this._register(new dom.DisposableResizeObserver(() => { + const toolbarsResizeObserver = this._register(new dom.DisposableResizeObserver('ChatInputPart.compactToolbars', () => { // Have to layout the editor when the toolbars change size, when they share width with the editor. // This handles ensuring we layout when quick chat is shown/hidden. // The toolbar may have changed since the last time it was visible. From 0c4d442c2d66f9b35274a7a81c2489ee312877a4 Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Tue, 5 May 2026 16:10:32 -0700 Subject: [PATCH 07/34] dom test: swallow expected onUnexpectedError in throwing-callback test --- src/vs/base/test/browser/dom.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/vs/base/test/browser/dom.test.ts b/src/vs/base/test/browser/dom.test.ts index 184b958e93ba6..144199cedf64f 100644 --- a/src/vs/base/test/browser/dom.test.ts +++ b/src/vs/base/test/browser/dom.test.ts @@ -8,6 +8,7 @@ import { $, h, trackAttributes, copyAttributes, disposableWindowInterval, getWin import { asCssValueWithDefault } from '../../../base/browser/cssValue.js'; import { ensureCodeWindow, isAuxiliaryWindow, mainWindow } from '../../browser/window.js'; import { DeferredPromise, timeout } from '../../common/async.js'; +import { errorHandler, setUnexpectedErrorHandler } from '../../common/errors.js'; import { runWithFakedTimers } from '../common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../common/utils.js'; @@ -609,8 +610,16 @@ suite('dom', () => { const observer = new DisposableResizeObserver('test.throw', () => { throw new Error('boom'); }, mainWindow, { resizeObserverCtor: fake.ctor }); // Browser would not catch a throw out of the native callback; we // must guard so a single bad consumer does not break delivery for - // every other observer in the realm. - assert.doesNotThrow(() => fake.fire([fakeEntry()])); + // every other observer in the realm. The wrapper routes the throw + // to onUnexpectedError, so swap the handler for the duration of + // this test so the test runner does not flag it as a failure. + const originalErrorHandler = errorHandler.getUnexpectedErrorHandler(); + setUnexpectedErrorHandler(() => { /* swallow expected */ }); + try { + assert.doesNotThrow(() => fake.fire([fakeEntry()])); + } finally { + setUnexpectedErrorHandler(originalErrorHandler); + } observer.dispose(); }); From 0267282d6b00568cf911b212ff1b4588129dc73e Mon Sep 17 00:00:00 2001 From: Anisha Agarwal Date: Tue, 5 May 2026 21:35:58 -0700 Subject: [PATCH 08/34] adding request.options.tools event to 3p --- .../src/extension/prompt/node/chatMLFetcher.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts index 06b1621dad469..bab0c1f179d43 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts @@ -1145,6 +1145,14 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { } this._telemetryService.sendGHTelemetryEvent('request.sent', telemetryData.properties, telemetryData.measurements); + if (request.tools) { + this._telemetryService.sendEnhancedGHTelemetryEvent('request.options.tools', { + headerRequestId: ourRequestId, + conversationId, + messagesJson: JSON.stringify(request.tools), + }, telemetryData.measurements); + } + const requestStart = Date.now(); const handle = connection.sendRequest(request, { userInitiated: !!userInitiatedRequest, turnId, requestId: ourRequestId, model: chatEndpointInfo.model, countTokens, tokenCountMax: chatEndpointInfo.maxOutputTokens, modelMaxPromptTokens: chatEndpointInfo.modelMaxPromptTokens, summarizedAtRoundId, modeChanged }, cancellationToken); @@ -1417,6 +1425,14 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { this._telemetryService.sendGHTelemetryEvent('request.sent', telemetryData.properties, telemetryData.measurements); + if (request.tools) { + this._telemetryService.sendEnhancedGHTelemetryEvent('request.options.tools', { + headerRequestId: ourRequestId, + conversationId: telemetryProperties?.conversationId, + messagesJson: JSON.stringify(request.tools), + }, telemetryData.measurements); + } + const requestStart = Date.now(); const intent = locationToIntent(location); From 78f58709a4cac6f7c8e9b278511eabf65a853c5e Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Wed, 6 May 2026 18:17:15 +0200 Subject: [PATCH 09/34] add logs for unexpected InstancePolicy.prompt in sessions window --- .../workbench/contrib/tasks/browser/abstractTaskService.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts b/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts index 808dcdd8486e6..8f47275eae419 100644 --- a/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts +++ b/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts @@ -2132,7 +2132,10 @@ export abstract class AbstractTaskService extends Disposable implements ITaskSer this._notificationService.warn(nls.localize('TaskSystem.InstancePolicy.warn', 'The instance limit for this task has been reached.')); break; case InstancePolicy.prompt: - default: + default: { + if (this._environmentService.isSessionsWindow) { + this._logService.warn(`[tasks] InstancePolicy.prompt hit in sessions window for task '${task._label}'\n${new Error().stack}`); + } this._showQuickPick(this._taskSystem!.getActiveTasks().filter(t => task._id === t._id), nls.localize('TaskService.instanceToTerminate', 'Select an instance to terminate'), { @@ -2148,6 +2151,7 @@ export abstract class AbstractTaskService extends Disposable implements ITaskSer } this._restart(task); }); + } } } From 4fa056e80f783eea76588b90b31c13f3be6c7877 Mon Sep 17 00:00:00 2001 From: Anisha Agarwal Date: Wed, 6 May 2026 10:00:32 -0700 Subject: [PATCH 10/34] multiplex in case it's too long --- .../copilot/src/extension/prompt/node/chatMLFetcher.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts index bab0c1f179d43..785b4d10e3a0e 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts @@ -32,7 +32,7 @@ import { IOTelService, ISpanHandle, SpanKind, SpanStatusCode } from '../../../pl import { IRequestLogger } from '../../../platform/requestLogger/common/requestLogger'; import { getCurrentCapturingToken } from '../../../platform/requestLogger/node/requestLogger'; import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; -import { ITelemetryService, TelemetryProperties } from '../../../platform/telemetry/common/telemetry'; +import { ITelemetryService, TelemetryProperties, multiplexProperties } from '../../../platform/telemetry/common/telemetry'; import { TelemetryData } from '../../../platform/telemetry/common/telemetryData'; import { isEncryptedThinkingDelta } from '../../../platform/thinking/common/thinking'; import { calculateLineRepetitionStats, isRepetitive } from '../../../util/common/anomalyDetection'; @@ -1146,11 +1146,11 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { this._telemetryService.sendGHTelemetryEvent('request.sent', telemetryData.properties, telemetryData.measurements); if (request.tools) { - this._telemetryService.sendEnhancedGHTelemetryEvent('request.options.tools', { + this._telemetryService.sendEnhancedGHTelemetryEvent('request.options.tools', multiplexProperties({ headerRequestId: ourRequestId, conversationId, messagesJson: JSON.stringify(request.tools), - }, telemetryData.measurements); + }), telemetryData.measurements); } const requestStart = Date.now(); @@ -1426,11 +1426,11 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { this._telemetryService.sendGHTelemetryEvent('request.sent', telemetryData.properties, telemetryData.measurements); if (request.tools) { - this._telemetryService.sendEnhancedGHTelemetryEvent('request.options.tools', { + this._telemetryService.sendEnhancedGHTelemetryEvent('request.options.tools', multiplexProperties({ headerRequestId: ourRequestId, conversationId: telemetryProperties?.conversationId, messagesJson: JSON.stringify(request.tools), - }, telemetryData.measurements); + }), telemetryData.measurements); } const requestStart = Date.now(); From 5aad0a4e35db275f1468115e5b832e590e9c5b6a Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Wed, 6 May 2026 10:04:21 -0700 Subject: [PATCH 11/34] dom: scope DisposableResizeObserver attribution to the same task via microtask The slot is set just before the user callback runs and cleared by a microtask in the same step, so the attribution helper only returns a name when the loop warning fires in the same task as the offending observer's callback. Unrelated errors fired later in the renderer no longer inherit a stale observer name. --- src/vs/base/browser/dom.ts | 18 +++++++++++++++--- src/vs/base/test/browser/dom.test.ts | 16 ++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index ca288f06badf9..ba62f85274849 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -2095,6 +2095,18 @@ export class DisposableResizeObserver extends Disposable { const ctor = options?.resizeObserverCtor ?? targetWindow.ResizeObserver; this.observer = new ctor((entries: ResizeObserverEntry[], observer) => { _lastInvokedDisposableResizeObserver = this; + // Microtasks run after the current task (the resize-observation + // phase + any synchronous `window.onerror` handlers triggered by + // the loop warning) but before the next task. Clearing the slot + // here scopes attribution to the same task that produced the + // warning, so unrelated errors fired later in the renderer don't + // inherit a stale observer name. The equality check ensures only + // the *last* writer in a phase actually clears the slot. + queueMicrotask(() => { + if (_lastInvokedDisposableResizeObserver === this) { + _lastInvokedDisposableResizeObserver = undefined; + } + }); try { callback(entries, observer); } catch (e) { @@ -2112,10 +2124,10 @@ export class DisposableResizeObserver extends Disposable { /** * The most recently invoked `DisposableResizeObserver`. Set just before each - * user callback runs and never cleared. The + * user callback runs and cleared by a microtask scheduled in the same step, + * so the slot only carries an attribution within the same task as the * `ResizeObserver loop completed with undelivered notifications` warning - * fires as a stackless `ErrorEvent` after callbacks have run, so this slot - * still points at a plausible suspect when telemetry asks about it. + * (which fires synchronously at the end of the resize-observation phase). */ let _lastInvokedDisposableResizeObserver: DisposableResizeObserver | undefined; diff --git a/src/vs/base/test/browser/dom.test.ts b/src/vs/base/test/browser/dom.test.ts index 144199cedf64f..6ca09e9ec4501 100644 --- a/src/vs/base/test/browser/dom.test.ts +++ b/src/vs/base/test/browser/dom.test.ts @@ -655,6 +655,22 @@ suite('dom', () => { a.dispose(); b.dispose(); }); + + test('getRecentDisposableResizeObserverAttributionForLoopError clears after a microtask so unrelated later errors are not mis-attributed', async () => { + const fake = createFakeResizeObserverCtor(); + const observer = new DisposableResizeObserver('scoped', () => { /* noop */ }, mainWindow, { resizeObserverCtor: fake.ctor }); + fake.fire([fakeEntry()]); + // Slot is set synchronously; attribution still works in this tick. + assert.ok(getRecentDisposableResizeObserverAttributionForLoopError('ResizeObserver loop completed with undelivered notifications.')); + // Yield once so the wrapper's queueMicrotask cleanup runs. + await Promise.resolve(); + assert.strictEqual( + getRecentDisposableResizeObserverAttributionForLoopError('ResizeObserver loop completed with undelivered notifications.'), + undefined, + 'slot must be cleared by the next microtask so an unrelated later error does not inherit a stale observer name', + ); + observer.dispose(); + }); }); ensureNoDisposablesAreLeakedInTestSuite(); From 99e7a43de63a744c7acab00084a163555b75ae45 Mon Sep 17 00:00:00 2001 From: Anisha Agarwal Date: Wed, 6 May 2026 10:06:28 -0700 Subject: [PATCH 12/34] add tests --- .../chatMLFetcherResponseApiTelemetry.spec.ts | 343 ++++++++++++++++++ 1 file changed, 343 insertions(+) diff --git a/extensions/copilot/src/extension/prompt/node/test/chatMLFetcherResponseApiTelemetry.spec.ts b/extensions/copilot/src/extension/prompt/node/test/chatMLFetcherResponseApiTelemetry.spec.ts index dc810554a77bd..4feca1c578cee 100644 --- a/extensions/copilot/src/extension/prompt/node/test/chatMLFetcherResponseApiTelemetry.spec.ts +++ b/extensions/copilot/src/extension/prompt/node/test/chatMLFetcherResponseApiTelemetry.spec.ts @@ -159,8 +159,351 @@ describe('ChatMLFetcherImpl Response API telemetry', () => { }); }); +describe('ChatMLFetcherImpl request.options.tools telemetry', () => { + let disposables: DisposableStore; + let fetcher: ChatMLFetcherImpl; + let mockFetcherService: MockFetcherService; + let spyingTelemetryService: SpyingTelemetryService; + let cancellationTokenSource: CancellationTokenSource; + + beforeEach(() => { + disposables = new DisposableStore(); + cancellationTokenSource = disposables.add(new CancellationTokenSource()); + + mockFetcherService = new MockFetcherService(); + spyingTelemetryService = new SpyingTelemetryService(); + const configurationService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService()); + + const logService = new TestLogService(); + const experimentationService = new NullExperimentationService(); + + fetcher = new ChatMLFetcherImpl( + mockFetcherService as unknown as IFetcherService, + spyingTelemetryService, + new NullRequestLogger(), + logService, + new TestAuthenticationService() as unknown as IAuthenticationService, + createMockInteractionService(), + createMockChatQuotaService(), + new TestCAPIClientService() as unknown as ICAPIClientService, + createMockConversationOptions(), + configurationService, + experimentationService, + createMockPowerService(), + new InstantiationServiceBuilder([ + [IFetcherService, mockFetcherService as unknown as IFetcherService], + [ITelemetryService, spyingTelemetryService], + [ICAPIClientService, new TestCAPIClientService() as unknown as ICAPIClientService], + ]).seal() as unknown as IInstantiationService, + new NullChatWebSocketManager(), + new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), + ); + }); + + afterEach(() => { + disposables.dispose(); + }); + + it('emits request.options.tools event when tools are present', async () => { + const endpointWithTools = createEndpointWithTools(); + mockFetcherService.queueResponse(createSuccessResponse('Hello!')); + + const opts: IFetchMLOptions = { + debugName: 'test-tools-telemetry', + messages: [{ role: Raw.ChatRole.User, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Use a tool' }] }], + endpoint: endpointWithTools, + location: ChatLocation.Panel, + requestOptions: {}, + finishedCb: undefined, + }; + + await fetcher.fetchMany(opts, cancellationTokenSource.token); + + const events = spyingTelemetryService.getEvents(); + const toolsEvents = events.telemetryServiceEvents.filter( + e => e.eventName === 'request.options.tools' + ); + + expect(toolsEvents.length).toBe(1); + const props = toolsEvents[0]!.properties as Record; + expect(props.headerRequestId).toBeDefined(); + expect(props.messagesJson).toBeDefined(); + + const toolsPayload = JSON.parse(props.messagesJson); + expect(toolsPayload.length).toBe(1); + expect(toolsPayload[0].function.name).toBe('get_weather'); + }); + + it('does not emit request.options.tools event when no tools are present', async () => { + const endpointWithoutTools = createChatCompletionEndpointWithoutTools(); + mockFetcherService.queueResponse(createSuccessResponse('Hello!')); + + const opts: IFetchMLOptions = { + debugName: 'test-no-tools-telemetry', + messages: [{ role: Raw.ChatRole.User, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }] }], + endpoint: endpointWithoutTools, + location: ChatLocation.Panel, + requestOptions: {}, + finishedCb: undefined, + }; + + await fetcher.fetchMany(opts, cancellationTokenSource.token); + + const events = spyingTelemetryService.getEvents(); + const toolsEvents = events.telemetryServiceEvents.filter( + e => e.eventName === 'request.options.tools' + ); + + expect(toolsEvents.length).toBe(0); + }); + + it('multiplexes messagesJson when tool schemas exceed 8KB', async () => { + const endpointWithLargeTools = createEndpointWithLargeTools(); + mockFetcherService.queueResponse(createSuccessResponse('Hello!')); + + const opts: IFetchMLOptions = { + debugName: 'test-large-tools-telemetry', + messages: [{ role: Raw.ChatRole.User, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Use a tool' }] }], + endpoint: endpointWithLargeTools, + location: ChatLocation.Panel, + requestOptions: {}, + finishedCb: undefined, + }; + + await fetcher.fetchMany(opts, cancellationTokenSource.token); + + const events = spyingTelemetryService.getEvents(); + const toolsEvents = events.telemetryServiceEvents.filter( + e => e.eventName === 'request.options.tools' + ); + + expect(toolsEvents.length).toBe(1); + const props = toolsEvents[0]!.properties as Record; + + // The messagesJson value exceeds 8192 chars, so multiplexProperties should chunk it + expect(props.messagesJson).toBeDefined(); + expect(props.messagesJson_02).toBeDefined(); + + // Reassemble the chunked value and verify it's valid JSON + let fullJson = props.messagesJson; + let i = 2; + while (props[`messagesJson_${String(i).padStart(2, '0')}`]) { + fullJson += props[`messagesJson_${String(i).padStart(2, '0')}`]; + i++; + } + const toolsPayload = JSON.parse(fullJson); + expect(toolsPayload.length).toBeGreaterThan(0); + expect(toolsPayload[0].function.name).toBe('large_tool_0'); + }); +}); + // --- Test Helpers --- +function createEndpointWithTools(): IChatEndpoint { + return { + url: 'https://api.github.com/copilot/chat/completions', + urlOrRequestMetadata: 'https://api.github.com/copilot/chat/completions', + model: 'test-model', + modelMaxPromptTokens: 8192, + maxOutputTokens: 4096, + supportsToolCalls: true, + supportsVision: false, + supportsPrediction: false, + showInModelPicker: true, + isDefault: true, + isFallback: false, + policy: 'enabled', + getHeaders: async () => ({}), + createRequestBody: (): IEndpointBody => ({ + model: 'test-model', + messages: [{ role: 'user', content: 'Use a tool' }], + stream: true, + tools: [{ + type: 'function', + function: { + name: 'get_weather', + description: 'Get the weather for a location', + parameters: { type: 'object', properties: { location: { type: 'string' } } }, + }, + }], + }), + acquireTokenizer: () => ({ + countMessagesTokens: async () => 100, + countTokens: async () => 100, + tokenize: async () => [], + }), + processResponseFromChatEndpoint: async (_telemetryService: ITelemetryService, _logService: ILogService, response: Response, _expectedNumChoices: number, finishedCb: FinishedCallback, telemetryData: TelemetryData, _cancellationToken?: CancellationToken) => { + const text = await response.text(); + if (finishedCb) { + await finishedCb(text, 0, { text }); + } + return { + [Symbol.asyncIterator]: async function* () { + yield { + message: { role: Raw.ChatRole.Assistant, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text }] }, + choiceIndex: 0, + requestId: { + headerRequestId: response.headers.get('x-request-id') || 'test-request-id', + gitHubRequestId: response.headers.get('x-github-request-id') || '', + completionId: '', + created: 0, + serverExperiments: '', + deploymentId: '', + }, + tokens: [], + usage: undefined, + model: 'test-model', + blockFinished: true, + finishReason: 'stop', + telemetryData: telemetryData, + }; + } + }; + }, + acceptChatPolicy: async () => true, + doRequest: async () => { + throw new Error('Not implemented'); + }, + } as unknown as IChatEndpoint; +} + +function createChatCompletionEndpointWithoutTools(): IChatEndpoint { + return { + url: 'https://api.github.com/copilot/chat/completions', + urlOrRequestMetadata: 'https://api.github.com/copilot/chat/completions', + model: 'test-model', + modelMaxPromptTokens: 8192, + maxOutputTokens: 4096, + supportsToolCalls: true, + supportsVision: false, + supportsPrediction: false, + showInModelPicker: true, + isDefault: true, + isFallback: false, + policy: 'enabled', + getHeaders: async () => ({}), + createRequestBody: (): IEndpointBody => ({ + model: 'test-model', + messages: [{ role: 'user', content: 'Hello' }], + stream: true, + // No tools field + }), + acquireTokenizer: () => ({ + countMessagesTokens: async () => 100, + countTokens: async () => 100, + tokenize: async () => [], + }), + processResponseFromChatEndpoint: async (_telemetryService: ITelemetryService, _logService: ILogService, response: Response, _expectedNumChoices: number, finishedCb: FinishedCallback, telemetryData: TelemetryData, _cancellationToken?: CancellationToken) => { + const text = await response.text(); + if (finishedCb) { + await finishedCb(text, 0, { text }); + } + return { + [Symbol.asyncIterator]: async function* () { + yield { + message: { role: Raw.ChatRole.Assistant, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text }] }, + choiceIndex: 0, + requestId: { + headerRequestId: response.headers.get('x-request-id') || 'test-request-id', + gitHubRequestId: response.headers.get('x-github-request-id') || '', + completionId: '', + created: 0, + serverExperiments: '', + deploymentId: '', + }, + tokens: [], + usage: undefined, + model: 'test-model', + blockFinished: true, + finishReason: 'stop', + telemetryData: telemetryData, + }; + } + }; + }, + acceptChatPolicy: async () => true, + doRequest: async () => { + throw new Error('Not implemented'); + }, + } as unknown as IChatEndpoint; +} + +function createEndpointWithLargeTools(): IChatEndpoint { + // Generate tools with schemas large enough to exceed 8192 chars when JSON.stringified + const largeTools = Array.from({ length: 20 }, (_, i) => ({ + type: 'function' as const, + function: { + name: `large_tool_${i}`, + description: 'A'.repeat(500), + parameters: { + type: 'object', + properties: Object.fromEntries( + Array.from({ length: 10 }, (_, j) => [`param_${j}`, { type: 'string', description: 'B'.repeat(50) }]) + ), + }, + }, + })); + + return { + url: 'https://api.github.com/copilot/chat/completions', + urlOrRequestMetadata: 'https://api.github.com/copilot/chat/completions', + model: 'test-model', + modelMaxPromptTokens: 8192, + maxOutputTokens: 4096, + supportsToolCalls: true, + supportsVision: false, + supportsPrediction: false, + showInModelPicker: true, + isDefault: true, + isFallback: false, + policy: 'enabled', + getHeaders: async () => ({}), + createRequestBody: (): IEndpointBody => ({ + model: 'test-model', + messages: [{ role: 'user', content: 'Use a tool' }], + stream: true, + tools: largeTools, + }), + acquireTokenizer: () => ({ + countMessagesTokens: async () => 100, + countTokens: async () => 100, + tokenize: async () => [], + }), + processResponseFromChatEndpoint: async (_telemetryService: ITelemetryService, _logService: ILogService, response: Response, _expectedNumChoices: number, finishedCb: FinishedCallback, telemetryData: TelemetryData, _cancellationToken?: CancellationToken) => { + const text = await response.text(); + if (finishedCb) { + await finishedCb(text, 0, { text }); + } + return { + [Symbol.asyncIterator]: async function* () { + yield { + message: { role: Raw.ChatRole.Assistant, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text }] }, + choiceIndex: 0, + requestId: { + headerRequestId: response.headers.get('x-request-id') || 'test-request-id', + gitHubRequestId: response.headers.get('x-github-request-id') || '', + completionId: '', + created: 0, + serverExperiments: '', + deploymentId: '', + }, + tokens: [], + usage: undefined, + model: 'test-model', + blockFinished: true, + finishReason: 'stop', + telemetryData: telemetryData, + }; + } + }; + }, + acceptChatPolicy: async () => true, + doRequest: async () => { + throw new Error('Not implemented'); + }, + } as unknown as IChatEndpoint; +} + /** * Creates an endpoint that returns Response API format request body (with input instead of messages) */ From 803ca3548b4bb5a5632e825ecce1ccd4c07f327a Mon Sep 17 00:00:00 2001 From: Paul Date: Wed, 6 May 2026 11:39:25 -0700 Subject: [PATCH 13/34] Show relative pricing in model picker (#314541) --- .../claude/node/claudeCodeModels.ts | 1 + .../vscode-node/languageModelAccess.ts | 1 + .../endpoint/common/endpointProvider.ts | 1 + .../platform/endpoint/node/chatEndpoint.ts | 2 + .../platform/networking/common/networking.ts | 1 + .../actionWidget/browser/actionList.ts | 48 +++++--- .../actionWidget/browser/actionWidget.css | 7 ++ .../api/common/extHostLanguageModels.ts | 2 + .../browser/widget/input/chatModelPicker.ts | 104 +++++++++++++----- .../widget/input/modelPickerActionItem.ts | 5 +- .../chat/browser/widget/media/chat.css | 5 + .../contrib/chat/common/languageModels.ts | 1 + .../widget/input/chatModelPicker.test.ts | 41 ++++++- .../vscode.proposed.languageModelPricing.d.ts | 12 ++ 14 files changed, 176 insertions(+), 55 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts index 54425c7a1dc5f..7fdc33590f357 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts @@ -101,6 +101,7 @@ export class ClaudeCodeModels extends Disposable implements IClaudeCodeModels { outputCost: endpoint.tokenPricing?.outputPrice, cacheCost: endpoint.tokenPricing?.cacheReadTokenPrice, multiplierNumeric: endpoint.multiplier, + priceCategory: endpoint.priceCategory, tooltip, isUserSelectable: true, configurationSchema: buildConfigurationSchema(endpoint), diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts index abcc80caa8791..54ede56906610 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts @@ -263,6 +263,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib outputCost: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.tokenPricing?.outputPrice, cacheCost: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.tokenPricing?.cacheReadTokenPrice, multiplierNumeric: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.multiplier, + priceCategory: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.priceCategory, detail: modelDetail, category: modelCategory, statusIcon: endpoint.degradationReason ? new vscode.ThemeIcon('warning') : undefined, diff --git a/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts b/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts index a49669107c5bd..44c9de15ed18d 100644 --- a/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts +++ b/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts @@ -105,6 +105,7 @@ export interface IModelAPIResponse { warning_messages?: { code: string; message: string }[]; info_messages?: { code: string; message: string }[]; billing?: IModelBilling; + model_picker_price_category?: string; capabilities: IChatModelCapabilities | ICompletionModelCapabilities | IEmbeddingModelCapabilities; supported_endpoints?: ModelSupportedEndpoint[]; custom_model?: CustomModel; diff --git a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts index aac3667891401..47273bf9c1fdc 100644 --- a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts @@ -158,6 +158,7 @@ export class ChatEndpoint implements IChatEndpoint { public readonly multiplier?: number | undefined; public readonly restrictedToSkus?: string[] | undefined; public readonly tokenPricing?: IChatEndpointTokenPricing | undefined; + public readonly priceCategory?: string | undefined; public readonly customModel?: CustomModel | undefined; public readonly maxPromptImages?: number | undefined; @@ -189,6 +190,7 @@ export class ChatEndpoint implements IChatEndpoint { this.multiplier = modelMetadata.billing?.multiplier; this.restrictedToSkus = modelMetadata.billing?.restricted_to; this.tokenPricing = normalizeTokenPricing(modelMetadata.billing?.token_prices); + this.priceCategory = modelMetadata.model_picker_price_category; this.isFallback = modelMetadata.is_chat_fallback; this.supportsToolCalls = !!modelMetadata.capabilities.supports.tool_calls; this.supportsVision = !!modelMetadata.capabilities.supports.vision; diff --git a/extensions/copilot/src/platform/networking/common/networking.ts b/extensions/copilot/src/platform/networking/common/networking.ts index f7128462008ce..e1d8b586dc388 100644 --- a/extensions/copilot/src/platform/networking/common/networking.ts +++ b/extensions/copilot/src/platform/networking/common/networking.ts @@ -294,6 +294,7 @@ export interface IChatEndpoint extends IEndpoint { * and normalizing to per-million-token rates based on batch_size. */ readonly tokenPricing?: IChatEndpointTokenPricing; + readonly priceCategory?: string; readonly isFallback: boolean; readonly customModel?: CustomModel; readonly isExtensionContributed?: boolean; diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index db39d0aa1568b..5e8a2cbe026a9 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -502,6 +502,11 @@ export interface IActionListOptions { * where this hint is misleading. */ readonly hideDefaultKeybindingTooltip?: boolean; + + /** + * Optional label shown on the right side of the filter row. + */ + readonly secondaryHeading?: string; } /** @@ -658,30 +663,37 @@ export class ActionListWidget extends Disposable { this._allMenuItems = [...items]; - // Create filter input - if (this._options?.showFilter) { + // Create filter input and/or secondary heading + if (this._options?.showFilter || this._options?.secondaryHeading) { this._filterContainer = document.createElement('div'); this._filterContainer.className = 'action-list-filter'; const filterRow = dom.append(this._filterContainer, dom.$('.action-list-filter-row')); - this._filterInput = document.createElement('input'); - this._filterInput.type = 'text'; - this._filterInput.className = 'action-list-filter-input'; - this._filterInput.placeholder = this._options?.filterPlaceholder ?? localize('actionList.filter.placeholder', "Search..."); - this._filterInput.setAttribute('aria-label', localize('actionList.filter.ariaLabel', "Filter items")); - filterRow.appendChild(this._filterInput); - - const filterActions = this._options?.filterActions ?? []; - if (filterActions.length > 0) { - const filterActionsContainer = dom.append(filterRow, dom.$('.action-list-filter-actions')); - const filterActionBar = this._register(new ActionBar(filterActionsContainer)); - filterActionBar.push(filterActions, { icon: true, label: false }); + if (this._options?.showFilter) { + this._filterInput = document.createElement('input'); + this._filterInput.type = 'text'; + this._filterInput.className = 'action-list-filter-input'; + this._filterInput.placeholder = this._options?.filterPlaceholder ?? localize('actionList.filter.placeholder', "Search..."); + this._filterInput.setAttribute('aria-label', localize('actionList.filter.ariaLabel', "Filter items")); + filterRow.appendChild(this._filterInput); + + const filterActions = this._options?.filterActions ?? []; + if (filterActions.length > 0) { + const filterActionsContainer = dom.append(filterRow, dom.$('.action-list-filter-actions')); + const filterActionBar = this._register(new ActionBar(filterActionsContainer)); + filterActionBar.push(filterActions, { icon: true, label: false }); + } + + this._register(dom.addDisposableListener(this._filterInput, 'input', () => { + this._filterText = this._filterInput!.value; + this._applyOrUpdateFilter(); + })); } - this._register(dom.addDisposableListener(this._filterInput, 'input', () => { - this._filterText = this._filterInput!.value; - this._applyOrUpdateFilter(); - })); + if (this._options?.secondaryHeading) { + const filterLabelEl = dom.append(filterRow, dom.$('.action-list-filter-label')); + filterLabelEl.textContent = this._options.secondaryHeading; + } } this._applyFilter(); diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index 18b5b0ba26cc5..7b79a90b33700 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -358,6 +358,13 @@ background-color: var(--vscode-toolbar-hoverBackground); } +.action-widget .action-list-filter-label { + color: var(--vscode-descriptionForeground); + font-size: 12px; + flex-shrink: 0; + margin-right: 2px; +} + /* Anchor for the absolutely-positioned submenu panel */ .action-widget .actionList { position: relative; diff --git a/src/vs/workbench/api/common/extHostLanguageModels.ts b/src/vs/workbench/api/common/extHostLanguageModels.ts index 96ebc6a543594..17eeef6f4ab98 100644 --- a/src/vs/workbench/api/common/extHostLanguageModels.ts +++ b/src/vs/workbench/api/common/extHostLanguageModels.ts @@ -226,6 +226,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { inputCost: m.inputCost, outputCost: m.outputCost, cacheCost: m.cacheCost, + priceCategory: m.priceCategory, maxInputTokens: m.maxInputTokens, maxOutputTokens: m.maxOutputTokens, auth, @@ -421,6 +422,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { inputCost: model.metadata.inputCost, outputCost: model.metadata.outputCost, cacheCost: model.metadata.cacheCost, + priceCategory: model.metadata.priceCategory, capabilities: { supportsImageToText: model.metadata.capabilities?.vision ?? false, supportsToolCalling: !!model.metadata.capabilities?.toolCalling, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index 1ce4a0814a008..13519940b2892 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -6,6 +6,9 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js'; import { renderIcon } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { getBaseLayerHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegate2.js'; +import { getDefaultHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { toAction } from '../../../../../../base/common/actions.js'; import { IStringDictionary } from '../../../../../../base/common/collections.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; @@ -99,13 +102,14 @@ function isMultiplierPricing(model: ILanguageModelChatMetadataAndIdentifier): bo function createModelItem( action: IActionWidgetDropdownAction & { section?: string }, model?: ILanguageModelChatMetadataAndIdentifier, + descriptionOverride?: string | MarkdownString, ): IActionListItem { const hoverContent = model ? getModelHoverContent(model) : undefined; return { item: action, kind: ActionListItemKind.Action, label: action.label, - description: action.description, + description: descriptionOverride ?? action.description, group: { title: '', icon: action.icon ?? ThemeIcon.fromId(action.checked ? Codicon.check.id : Codicon.blank.id) }, hideIcon: false, section: action.section, @@ -143,17 +147,21 @@ function resolveConfigProperty( } /** - * Returns a short description summarizing the model's current configuration values - * for properties marked with group 'navigation' (e.g., "High", "Medium"). + * Returns a visual pricing category indicator using codicon circles. + * One filled circle for "low", two for "medium", three for "high", four for "very_high". + * Empty circles are shown for the remaining slots (out of four total). */ -function getModelConfigurationDescription(model: ILanguageModelChatMetadataAndIdentifier, languageModelsService: ILanguageModelsService): string | undefined { - const config = resolveConfigProperty(model, 'navigation', languageModelsService); - if (!config || config.value === undefined) { - return undefined; +function getPriceCategoryIndicator(priceCategory: string | undefined): string | undefined { + let filled: number; + switch (priceCategory) { + case 'low': filled = 1; break; + case 'medium': filled = 2; break; + case 'high': filled = 3; break; + case 'very_high': filled = 4; break; + default: return undefined; } - const enumIndex = config.schema.enum?.indexOf(config.value) ?? -1; - const label = config.schema.enumItemLabels?.[enumIndex] ?? String(config.value); - return label; + const total = 4; + return '$(circle-filled)'.repeat(filled) + '$(circle)'.repeat(total - filled); } function createModelAction( @@ -162,28 +170,37 @@ function createModelAction( onSelect: (model: ILanguageModelChatMetadataAndIdentifier) => void, languageModelsService: ILanguageModelsService, section?: string, -): IActionWidgetDropdownAction & { section?: string } { - const configDescription = getModelConfigurationDescription(model, languageModelsService); +): { action: IActionWidgetDropdownAction & { section?: string }; descriptionOverride?: MarkdownString } { // Only show pricing in the description line if it's a multiplier (e.g. "2x"). // Detailed AIC/token pricing is shown in the hover instead. const pricingForDescription = isMultiplierPricing(model) ? model.metadata.pricing : undefined; - const detailParts = [model.metadata.detail, pricingForDescription].filter(Boolean); - const baseDescription = detailParts.length > 0 ? detailParts.join(' · ') : undefined; - const description = configDescription && baseDescription - ? `${configDescription} · ${baseDescription}` - : configDescription ?? baseDescription; - return { + const priceCategoryIndicator = getPriceCategoryIndicator(model.metadata.priceCategory); + const textParts = [model.metadata.detail, pricingForDescription].filter(Boolean); + const textDescription = textParts.length > 0 ? textParts.join(' · ') : undefined; + + let descriptionOverride: MarkdownString | undefined; + if (priceCategoryIndicator) { + const md = new MarkdownString('', { isTrusted: false, supportThemeIcons: true }); + if (textDescription) { + md.appendText(textDescription + ' · '); + } + md.appendMarkdown(priceCategoryIndicator); + descriptionOverride = md; + } + + const action: IActionWidgetDropdownAction & { section?: string } = { id: model.identifier, enabled: true, icon: model.metadata.statusIcon, checked: model.identifier === selectedModelId, class: undefined, - description, + description: priceCategoryIndicator ? undefined : textDescription, tooltip: model.metadata.name, label: model.metadata.name, section, run: () => onSelect(model), }; + return { action, descriptionOverride }; } function shouldShowManageModelsAction(chatEntitlementService: IChatEntitlementService): boolean { @@ -286,7 +303,8 @@ export function buildModelPickerItems( const autoModel = models.find(m => isAutoModel(m)); if (autoModel) { markPlaced(autoModel.identifier, autoModel.metadata.id); - items.push(createModelItem(createModelAction(autoModel, selectedModelId, onSelect, languageModelsService!), autoModel)); + const { action: autoAction, descriptionOverride: autoDesc } = createModelAction(autoModel, selectedModelId, onSelect, languageModelsService!); + items.push(createModelItem(autoAction, autoModel, autoDesc)); } // --- 2. Promoted section (selected + recently used + featured) --- @@ -374,7 +392,8 @@ export function buildModelPickerItems( for (const item of promotedItems) { if (item.kind === 'available') { - items.push(createModelItem(createModelAction(item.model, selectedModelId, onSelect, languageModelsService!), item.model)); + const { action: promotedAction, descriptionOverride: promotedDesc } = createModelAction(item.model, selectedModelId, onSelect, languageModelsService!); + items.push(createModelItem(promotedAction, item.model, promotedDesc)); } else { items.push(createUnavailableModelItem(item.id, item.entry, item.reason, manageSettingsUrl, updateStateType, chatEntitlementService)); } @@ -405,6 +424,9 @@ export function buildModelPickerItems( if (items.length > 0) { items.push({ kind: ActionListItemKind.Separator }); } + const otherModelsToolbar = manageModelsAction + ? [toAction({ id: manageModelsAction.id, label: manageModelsAction.tooltip ?? manageModelsAction.label, class: ThemeIcon.asClassName(Codicon.gear), run: () => manageModelsAction.run() })] + : undefined; items.push({ item: { id: 'otherModels', @@ -421,27 +443,30 @@ export function buildModelPickerItems( hideIcon: false, section: ModelPickerSection.Other, isSectionToggle: true, + toolbarActions: otherModelsToolbar, + className: 'chat-model-picker-section-toggle', }); for (const model of otherModels) { const entry = controlModels[model.metadata.id] ?? controlModels[model.identifier]; if (entry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { items.push(createUnavailableModelItem(model.metadata.id, entry, 'update', manageSettingsUrl, updateStateType, chatEntitlementService, ModelPickerSection.Other)); } else { - items.push(createModelItem(createModelAction(model, selectedModelId, onSelect, languageModelsService!, ModelPickerSection.Other), model)); + const { action: otherAction, descriptionOverride: otherDesc } = createModelAction(model, selectedModelId, onSelect, languageModelsService!, ModelPickerSection.Other); + items.push(createModelItem(otherAction, model, otherDesc)); } } } } - if (manageModelsAction) { - items.push({ kind: ActionListItemKind.Separator, section: otherModels.length ? ModelPickerSection.Other : undefined }); + if (manageModelsAction && !otherModels.length) { + // No Other Models section: show manage models as standalone + items.push({ kind: ActionListItemKind.Separator }); items.push({ item: manageModelsAction, kind: ActionListItemKind.Action, label: manageModelsAction.label, group: { title: '', icon: Codicon.blank }, hideIcon: false, - section: otherModels.length ? ModelPickerSection.Other : undefined, showAlways: true, }); } @@ -449,7 +474,8 @@ export function buildModelPickerItems( // Flat list: auto first, then all models sorted alphabetically const autoModel = models.find(m => isAutoModel(m)); if (autoModel) { - items.push(createModelItem(createModelAction(autoModel, selectedModelId, onSelect, languageModelsService!), autoModel)); + const { action: flatAutoAction, descriptionOverride: flatAutoDesc } = createModelAction(autoModel, selectedModelId, onSelect, languageModelsService!); + items.push(createModelItem(flatAutoAction, autoModel, flatAutoDesc)); } const sortedModels = models .filter(m => m !== autoModel) @@ -458,7 +484,8 @@ export function buildModelPickerItems( return vendorCmp !== 0 ? vendorCmp : a.metadata.name.localeCompare(b.metadata.name); }); for (const model of sortedModels) { - items.push(createModelItem(createModelAction(model, selectedModelId, onSelect, languageModelsService!), model)); + const { action: flatAction, descriptionOverride: flatDesc } = createModelAction(model, selectedModelId, onSelect, languageModelsService!); + items.push(createModelItem(flatAction, model, flatDesc)); } } @@ -581,6 +608,10 @@ export class ModelPickerWidget extends Disposable { return this._domNode; } + get nameButton(): HTMLElement | undefined { + return this._nameButton; + } + constructor( private readonly _delegate: IModelPickerDelegate, @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, @@ -666,6 +697,18 @@ export class ModelPickerWidget extends Disposable { this._registerButtonAction(this._nameButton, () => this.show()); this._registerButtonAction(this._effortButton, () => this._showEffortPicker()); this._registerButtonAction(this._tokensButton, () => this._cycleTokens()); + + // Managed hovers for effort and tokens buttons + this._register(getBaseLayerHoverDelegate().setupManagedHover( + getDefaultHoverDelegate('mouse'), + this._effortButton, + localize('chat.modelPicker.effortTooltip', "Set Thinking Effort") + )); + this._register(getBaseLayerHoverDelegate().setupManagedHover( + getDefaultHoverDelegate('mouse'), + this._tokensButton, + localize('chat.modelPicker.tokensTooltip', "Set Context Size") + )); } /** @@ -727,17 +770,20 @@ export class ModelPickerWidget extends Disposable { onSelect, manageSettingsUrl, this._delegate.useGroupedModelPicker(), - !showFilter ? manageModelsAction : undefined, + manageModelsAction, this._entitlementService, this._delegate.showUnavailableFeatured(), this._delegate.showFeatured(), this._languageModelsService, ); + const hasPriceCategories = models.some(m => !!m.metadata.priceCategory); + const listOptions = { showFilter, filterPlaceholder: localize('chat.modelPicker.search', "Search models"), - filterActions: showFilter && manageModelsAction ? [manageModelsAction] : undefined, + filterActions: undefined, + secondaryHeading: hasPriceCategories ? localize('chat.modelPicker.cost', "Cost") : undefined, focusFilterOnOpen: true, collapsedByDefault: new Set([ModelPickerSection.Other]), onDidToggleSection: (section: string, collapsed: boolean) => { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts index 6524fbec01cbf..fd834123ccf3f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts @@ -95,14 +95,15 @@ export class ModelPickerActionItem extends BaseActionViewItem { } private _updateTooltip(): void { - if (!this.element) { + const target = this._pickerWidget.nameButton; + if (!target) { return; } const hoverContent = this._getHoverContents(); if (typeof hoverContent === 'string' && hoverContent) { this._managedHover.value = getBaseLayerHoverDelegate().setupManagedHover( getDefaultHoverDelegate('mouse'), - this.element, + target, hoverContent ); } else { diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 1ea69a0b21693..f4a7078507dbc 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -1877,6 +1877,7 @@ have to be updated for changes to the rules above, or to support more deeply nes height: auto; border-radius: 0; position: relative; + cursor: default; } /* Dividers: only in the workbench chat input toolbar (not in the agents app) */ @@ -2063,6 +2064,10 @@ have to be updated for changes to the rules above, or to support more deeply nes margin-bottom: 4px; } +.action-widget .monaco-list-row.chat-model-picker-section-toggle.has-toolbar .action-list-item-toolbar { + display: flex; +} + .interactive-session .chat-input-toolbars .codicon-debug-stop { color: var(--vscode-icon-foreground) !important; } diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index ee301b00a841a..aa06621056919 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -195,6 +195,7 @@ export interface ILanguageModelChatMetadata { readonly inputCost?: number; readonly outputCost?: number; readonly cacheCost?: number; + readonly priceCategory?: string; readonly family: string; readonly maxInputTokens: number; readonly maxOutputTokens: number; diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts index a772a84de4063..011896bad5715 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts @@ -303,8 +303,8 @@ suite('buildModelPickerItems', () => { }); // With no selected, no recent, and no featured, both models should be in Other const seps = items.filter(i => i.kind === ActionListItemKind.Separator); - // One separator before Other Models section, one before Manage Models - assert.strictEqual(seps.length, 2); + // One separator before Other Models section (Manage Models is in the toolbar) + assert.strictEqual(seps.length, 1); const actions = getActionItems(items); assert.strictEqual(actions[0].label, 'Auto'); // Next should be "Other Models" toggle @@ -366,13 +366,15 @@ suite('buildModelPickerItems', () => { assert.ok(toggles[0].label!.includes('Other Models')); }); - test('Other Models section includes Manage Models entry', () => { + test('Other Models section includes Manage Models in toolbar', () => { const auto = createAutoModel(); const modelA = createModel('gpt-4o', 'GPT-4o'); const items = callBuild([auto, modelA]); - const manageItem = getActionItems(items).find(i => i.item?.id === 'manageModels'); - assert.ok(manageItem); - assert.ok(manageItem.label!.includes('Manage Models')); + const toggle = getActionItems(items).find(i => i.isSectionToggle); + assert.ok(toggle); + assert.ok(toggle.toolbarActions); + assert.strictEqual(toggle.toolbarActions!.length, 1); + assert.strictEqual(toggle.toolbarActions![0].id, 'manageModels'); }); test('Other Models with minVSCodeVersion that fails shows as disabled', () => { @@ -778,5 +780,32 @@ suite('buildModelPickerItems', () => { assert.ok(gptItem); assert.strictEqual(gptItem.item?.description, undefined); }); + + test('model with priceCategory shows MarkdownString description with circle indicators', () => { + const auto = createAutoModel(); + const modelA = createModel('gpt-4o', 'GPT-4o'); + modelA.metadata = { ...modelA.metadata, priceCategory: 'medium' } as ILanguageModelChatMetadata; + const items = callBuild([auto, modelA]); + const gptItem = getActionItems(items).find(a => a.label === 'GPT-4o'); + assert.ok(gptItem); + // When priceCategory is set, the action's plain description should be undefined + assert.strictEqual(gptItem.item?.description, undefined); + // The item's description should be a MarkdownString with circle icons + assert.ok(gptItem.description instanceof MarkdownString); + assert.ok(gptItem.description.value.includes('circle-filled')); + assert.ok(gptItem.description.value.includes('circle')); + }); + + test('model with unknown priceCategory shows no circle indicators', () => { + const auto = createAutoModel(); + const modelA = createModel('gpt-4o', 'GPT-4o'); + modelA.metadata = { ...modelA.metadata, priceCategory: 'unknown_tier' } as ILanguageModelChatMetadata; + const items = callBuild([auto, modelA]); + const gptItem = getActionItems(items).find(a => a.label === 'GPT-4o'); + assert.ok(gptItem); + // Unknown category should fall through to normal description (undefined since no detail) + assert.strictEqual(gptItem.item?.description, undefined); + assert.strictEqual(gptItem.description, undefined); + }); }); diff --git a/src/vscode-dts/vscode.proposed.languageModelPricing.d.ts b/src/vscode-dts/vscode.proposed.languageModelPricing.d.ts index 2ccf5e71d3815..b67bc7b55945b 100644 --- a/src/vscode-dts/vscode.proposed.languageModelPricing.d.ts +++ b/src/vscode-dts/vscode.proposed.languageModelPricing.d.ts @@ -31,6 +31,12 @@ declare module 'vscode' { * Displayed in the model management UI as the cost per million cached tokens. */ readonly cacheCost?: number; + + /** + * Optional relative pricing category for this model (e.g. "low", "medium", "high", "very_high"). + * Displayed in the model picker as a visual indicator of relative cost. + */ + readonly priceCategory?: string; } export interface LanguageModelChat { @@ -54,5 +60,11 @@ declare module 'vscode' { * Optional cache cost in AI credits for this model. */ readonly cacheCost?: number; + + /** + * Optional relative pricing category for this model (e.g. "low", "medium", "high", "very_high"). + * Displayed in the model picker as a visual indicator of relative cost. + */ + readonly priceCategory?: string; } } From c17280fcc113f1b7f83eef73332f031acb4270ff Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst <39639992+hawkticehurst@users.noreply.github.com> Date: Wed, 6 May 2026 14:45:20 -0400 Subject: [PATCH 14/34] chat: add copy microinteraction to code blocks (#314750) chat: add copy microinteraction to code blocks (#314490) Reuse the existing chat copy action view item for code block toolbars so the copy button shows the same copy-to-check transition, tooltip, ARIA label, and clipboard announcement as response copy actions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/actions/chatCodeblockActions.ts | 10 ++++++++++ .../chat/browser/actions/chatCopyActions.ts | 2 +- .../contrib/chat/browser/widget/media/chat.css | 14 ++++++++++---- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts index d5b7479cbf6c0..a2779d421f7c7 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts @@ -36,6 +36,7 @@ import { ChatCopyKind, IChatService } from '../../common/chatService/chatService import { IChatRequestViewModel, IChatResponseViewModel, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js'; import { ChatAgentLocation } from '../../common/constants.js'; import { IChatCodeBlockContextProviderService, IChatWidgetService } from '../chat.js'; +import { ChatCopyActionViewItem } from './chatCopyActions.js'; import { DefaultChatTextEditor, ICodeBlockActionContext, ICodeCompareBlockActionContext } from '../widget/chatContentParts/codeBlockPart.js'; import { CHAT_CATEGORY } from './chatActions.js'; import { ApplyCodeBlockOperation, InsertCodeBlockOperation } from './codeBlockOperations.js'; @@ -102,6 +103,14 @@ export class CodeBlockActionRendering extends Disposable implements IWorkbenchCo ) { super(); + const copyCodeBlockActionRendering = this._register(actionViewItemService.register(MenuId.ChatCodeBlock, 'workbench.action.chat.copyCodeBlock', (action, options) => { + if (!(action instanceof MenuItemAction)) { + return undefined; + } + + return instantiationService.createInstance(ChatCopyActionViewItem, action, options); + })); + const disposable = actionViewItemService.register(MenuId.ChatCodeBlock, APPLY_IN_EDITOR_ID, (action, options) => { if (!(action instanceof MenuItemAction)) { return undefined; @@ -123,6 +132,7 @@ export class CodeBlockActionRendering extends Disposable implements IWorkbenchCo }); // Reduces flicker a bit on reload/restart + markAsSingleton(copyCodeBlockActionRendering); markAsSingleton(disposable); } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts index a2910a1a9159d..3bcce67d1fd16 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts @@ -30,7 +30,7 @@ const copyFeedbackDuration = 1200; const copyIconClasses = ThemeIcon.asClassNameArray(Codicon.copy); const copiedIconClasses = ThemeIcon.asClassNameArray(Codicon.check); -class ChatCopyActionViewItem extends MenuEntryActionViewItem { +export class ChatCopyActionViewItem extends MenuEntryActionViewItem { private readonly copiedStateReset = this._register(new MutableDisposable()); private readonly actionRunnerListener = this._register(new MutableDisposable()); diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index f4a7078507dbc..b0cb727f36e97 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -217,19 +217,22 @@ gap: 4px; } -.interactive-item-container .chat-footer-toolbar .menu-entry.chat-copy-action .action-label { +.interactive-item-container .chat-footer-toolbar .menu-entry.chat-copy-action .action-label, +.interactive-result-code-block .interactive-result-code-block-toolbar .menu-entry.chat-copy-action .action-label { position: relative; overflow: hidden; } -.interactive-item-container .chat-footer-toolbar .menu-entry.chat-copy-action .chat-copy-action-icons { +.interactive-item-container .chat-footer-toolbar .menu-entry.chat-copy-action .chat-copy-action-icons, +.interactive-result-code-block .interactive-result-code-block-toolbar .menu-entry.chat-copy-action .chat-copy-action-icons { display: grid; place-items: center; width: 16px; height: 16px; } -.interactive-item-container .chat-footer-toolbar .menu-entry.chat-copy-action .chat-copy-action-icon { +.interactive-item-container .chat-footer-toolbar .menu-entry.chat-copy-action .chat-copy-action-icon, +.interactive-result-code-block .interactive-result-code-block-toolbar .menu-entry.chat-copy-action .chat-copy-action-icon { grid-area: 1 / 1; display: flex; align-items: center; @@ -244,12 +247,15 @@ } .interactive-item-container .chat-footer-toolbar .menu-entry.chat-copy-action:not(.copied) .chat-copy-action-icon-copy, -.interactive-item-container .chat-footer-toolbar .menu-entry.chat-copy-action.copied .chat-copy-action-icon-copied { +.interactive-result-code-block .interactive-result-code-block-toolbar .menu-entry.chat-copy-action:not(.copied) .chat-copy-action-icon-copy, +.interactive-item-container .chat-footer-toolbar .menu-entry.chat-copy-action.copied .chat-copy-action-icon-copied, +.interactive-result-code-block .interactive-result-code-block-toolbar .menu-entry.chat-copy-action.copied .chat-copy-action-icon-copied { opacity: 1; transform: scale(1); } @media (prefers-reduced-motion: reduce) { + .interactive-result-code-block .interactive-result-code-block-toolbar .menu-entry.chat-copy-action .chat-copy-action-icon, .interactive-item-container .chat-footer-toolbar .menu-entry.chat-copy-action .chat-copy-action-icon { transform: none; transition: none; From e7ba495c73148f70d37bbd50904541b92a3495c2 Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Wed, 6 May 2026 11:48:55 -0700 Subject: [PATCH 15/34] dom: scope DisposableResizeObserver attribution to the same task via setTimeout(0) queueMicrotask clears the slot too eagerly: Chromium runs a microtask checkpoint after each ResizeObserver callback returns, which is *before* the synthetic 'ResizeObserver loop completed with undelivered notifications' error fires at the end of the observation phase. A 0-ms timer runs after the entire current task (callbacks + loop-error dispatch) completes, so the slot is still set when window.onerror picks it up, while still being cleared before any unrelated error in the next task can inherit a stale name. Verified by hand in Code-OSS DevTools. --- src/vs/base/browser/dom.ts | 27 +++++++++++++++++---------- src/vs/base/test/browser/dom.test.ts | 13 ++++++++----- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index ba62f85274849..e943bf0a55775 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -2095,18 +2095,23 @@ export class DisposableResizeObserver extends Disposable { const ctor = options?.resizeObserverCtor ?? targetWindow.ResizeObserver; this.observer = new ctor((entries: ResizeObserverEntry[], observer) => { _lastInvokedDisposableResizeObserver = this; - // Microtasks run after the current task (the resize-observation - // phase + any synchronous `window.onerror` handlers triggered by - // the loop warning) but before the next task. Clearing the slot - // here scopes attribution to the same task that produced the - // warning, so unrelated errors fired later in the renderer don't - // inherit a stale observer name. The equality check ensures only - // the *last* writer in a phase actually clears the slot. - queueMicrotask(() => { + // Clear via a 0-ms timer (a new macrotask) rather than a + // microtask: microtask checkpoints run at the end of every + // script, i.e. after each ResizeObserver callback returns, but + // Chromium dispatches the synthetic `ResizeObserver loop + // completed with undelivered notifications` error *after* all + // callbacks in the observation phase finish. A microtask would + // clear the slot before the warning fires; a setTimeout(0) + // runs after the entire current task completes (callbacks + + // loop-error dispatch), so attribution survives long enough to + // be picked up by `window.onerror` while still being scoped to + // the same task that produced it. The equality check ensures + // only the *last* writer in a phase actually clears the slot. + setTimeout(() => { if (_lastInvokedDisposableResizeObserver === this) { _lastInvokedDisposableResizeObserver = undefined; } - }); + }, 0); try { callback(entries, observer); } catch (e) { @@ -2124,10 +2129,12 @@ export class DisposableResizeObserver extends Disposable { /** * The most recently invoked `DisposableResizeObserver`. Set just before each - * user callback runs and cleared by a microtask scheduled in the same step, + * user callback runs and cleared by a 0-ms timer scheduled in the same step, * so the slot only carries an attribution within the same task as the * `ResizeObserver loop completed with undelivered notifications` warning * (which fires synchronously at the end of the resize-observation phase). + * A microtask would be too eager — microtask checkpoints run between each + * callback, before the loop-error dispatch. */ let _lastInvokedDisposableResizeObserver: DisposableResizeObserver | undefined; diff --git a/src/vs/base/test/browser/dom.test.ts b/src/vs/base/test/browser/dom.test.ts index 6ca09e9ec4501..dd984a4bb0e2c 100644 --- a/src/vs/base/test/browser/dom.test.ts +++ b/src/vs/base/test/browser/dom.test.ts @@ -656,18 +656,21 @@ suite('dom', () => { b.dispose(); }); - test('getRecentDisposableResizeObserverAttributionForLoopError clears after a microtask so unrelated later errors are not mis-attributed', async () => { + test('getRecentDisposableResizeObserverAttributionForLoopError clears after the current task so unrelated later errors are not mis-attributed', async () => { const fake = createFakeResizeObserverCtor(); const observer = new DisposableResizeObserver('scoped', () => { /* noop */ }, mainWindow, { resizeObserverCtor: fake.ctor }); fake.fire([fakeEntry()]); - // Slot is set synchronously; attribution still works in this tick. - assert.ok(getRecentDisposableResizeObserverAttributionForLoopError('ResizeObserver loop completed with undelivered notifications.')); - // Yield once so the wrapper's queueMicrotask cleanup runs. + // Slot is set synchronously and survives microtasks (so it is + // still set when Chromium dispatches the loop warning at the end + // of the resize-observation phase). await Promise.resolve(); + assert.ok(getRecentDisposableResizeObserverAttributionForLoopError('ResizeObserver loop completed with undelivered notifications.')); + // A 0-ms timer (next macrotask) clears the slot. + await new Promise(resolve => setTimeout(resolve, 0)); assert.strictEqual( getRecentDisposableResizeObserverAttributionForLoopError('ResizeObserver loop completed with undelivered notifications.'), undefined, - 'slot must be cleared by the next microtask so an unrelated later error does not inherit a stale observer name', + 'slot must be cleared by the next task so an unrelated later error does not inherit a stale observer name', ); observer.dispose(); }); From 033f60045708a9af044814b537674fd66c2c5bba Mon Sep 17 00:00:00 2001 From: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> Date: Wed, 6 May 2026 13:56:08 -0700 Subject: [PATCH 16/34] chore: bump sanity test containers (#314778) --- build/azure-pipelines/product-build.yml | 48 +++++++++---------- .../azure-pipelines/product-sanity-tests.yml | 48 +++++++++---------- 2 files changed, 48 insertions(+), 48 deletions(-) diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index 6419dfafa9671..927081ca66250 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -525,32 +525,32 @@ extends: container: centos arch: arm64 - # Debian 10 + # Debian 11 - ${{ if eq(parameters.VSCODE_BUILD_LINUX, true) }}: - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: - name: debian_10_amd64 - displayName: Debian 10 amd64 + name: debian_11_amd64 + displayName: Debian 11 amd64 poolName: 1es-ubuntu-22.04-x64 - container: debian-10 + container: debian-11 arch: amd64 - ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true) }}: - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: - name: debian_10_arm32 - displayName: Debian 10 arm32 + name: debian_11_arm32 + displayName: Debian 11 arm32 poolName: 1es-azure-linux-3-arm64 - container: debian-10 + container: debian-11 arch: arm - ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARM64, true) }}: - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: - name: debian_10_arm64 - displayName: Debian 10 arm64 + name: debian_11_arm64 + displayName: Debian 11 arm64 poolName: 1es-azure-linux-3-arm64 - container: debian-10 + container: debian-11 arch: arm64 # Debian 12 @@ -581,42 +581,42 @@ extends: container: debian-12 arch: arm64 - # Fedora 36 + # Fedora 42 - ${{ if eq(parameters.VSCODE_BUILD_LINUX, true) }}: - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: - name: fedora_36_amd64 - displayName: Fedora 36 amd64 + name: fedora_42_amd64 + displayName: Fedora 42 amd64 poolName: 1es-ubuntu-22.04-x64 - container: fedora-36 + container: fedora-42 arch: amd64 - ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARM64, true) }}: - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: - name: fedora_36_arm64 - displayName: Fedora 36 arm64 + name: fedora_42_arm64 + displayName: Fedora 42 arm64 poolName: 1es-azure-linux-3-arm64 - container: fedora-36 + container: fedora-42 arch: arm64 - # Fedora 40 + # Fedora 43 - ${{ if eq(parameters.VSCODE_BUILD_LINUX, true) }}: - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: - name: fedora_40_amd64 - displayName: Fedora 40 amd64 + name: fedora_43_amd64 + displayName: Fedora 43 amd64 poolName: 1es-ubuntu-22.04-x64 - container: fedora-40 + container: fedora-43 arch: amd64 - ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARM64, true) }}: - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: - name: fedora_40_arm64 - displayName: Fedora 40 arm64 + name: fedora_43_arm64 + displayName: Fedora 43 arm64 poolName: 1es-azure-linux-3-arm64 - container: fedora-40 + container: fedora-43 arch: arm64 # openSUSE Leap 16.0 diff --git a/build/azure-pipelines/product-sanity-tests.yml b/build/azure-pipelines/product-sanity-tests.yml index a3926138f25ed..0af38d689ee23 100644 --- a/build/azure-pipelines/product-sanity-tests.yml +++ b/build/azure-pipelines/product-sanity-tests.yml @@ -128,29 +128,29 @@ extends: container: centos arch: arm64 - # Debian 10 + # Debian 11 - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: - name: debian_10_amd64 - displayName: Debian 10 amd64 + name: debian_11_amd64 + displayName: Debian 11 amd64 poolName: 1es-ubuntu-22.04-x64 - container: debian-10 + container: debian-11 arch: amd64 - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: - name: debian_10_arm32 - displayName: Debian 10 arm32 + name: debian_11_arm32 + displayName: Debian 11 arm32 poolName: 1es-azure-linux-3-arm64 - container: debian-10 + container: debian-11 arch: arm - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: - name: debian_10_arm64 - displayName: Debian 10 arm64 + name: debian_11_arm64 + displayName: Debian 11 arm64 poolName: 1es-azure-linux-3-arm64 - container: debian-10 + container: debian-11 arch: arm64 # Debian 12 @@ -178,38 +178,38 @@ extends: container: debian-12 arch: arm64 - # Fedora 36 + # Fedora 42 - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: - name: fedora_36_amd64 - displayName: Fedora 36 amd64 + name: fedora_42_amd64 + displayName: Fedora 42 amd64 poolName: 1es-ubuntu-22.04-x64 - container: fedora-36 + container: fedora-42 arch: amd64 - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: - name: fedora_36_arm64 - displayName: Fedora 36 arm64 + name: fedora_42_arm64 + displayName: Fedora 42 arm64 poolName: 1es-azure-linux-3-arm64 - container: fedora-36 + container: fedora-42 arch: arm64 - # Fedora 40 + # Fedora 43 - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: - name: fedora_40_amd64 - displayName: Fedora 40 amd64 + name: fedora_43_amd64 + displayName: Fedora 43 amd64 poolName: 1es-ubuntu-22.04-x64 - container: fedora-40 + container: fedora-43 arch: amd64 - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: - name: fedora_40_arm64 - displayName: Fedora 40 arm64 + name: fedora_43_arm64 + displayName: Fedora 43 arm64 poolName: 1es-azure-linux-3-arm64 - container: fedora-40 + container: fedora-43 arch: arm64 # openSUSE Leap 16.0 From 186edb065f060592d9020bceec3d4c5a7f806bf0 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Wed, 6 May 2026 14:07:44 -0700 Subject: [PATCH 17/34] chat: Discover plugins installed by Copilot CLI (#314805) * Discover plugins installed by Copilot CLI (#302152) Adds `CopilotCliAgentPluginDiscovery` which scans `~/.copilot/installed-plugins//< the two-levelplugin>/` directory layout used by the Copilot and surfaces each leafCLI directory as an `IAgentPlugin`, so CLI-installed plugins appear in VS Code without a separate install step. File watchers are set up on the root and each marketplace bucket so installs and uninstalls made via the CLI are reflected live. The remove() action prompts the user before deleting the directory from disk, since the CLI manages this location. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback - Watch the deepest existing ancestor of installed-plugins so the first-ever CLI install is detected without a reload (non-recursive Node watchers fail on ENOENT). - Use trash on UI-driven plugin removal and update the prompt detail to say so. - Wrap the recursive setupWatchers() call inside the change handler with a .catch() to keep watcher setup best-effort. - Update AGENTS_PLUGINS.md to accurately describe the (non-recursive) watcher strategy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/chat.contribution.ts | 3 +- .../chat/common/plugins/AGENTS_PLUGINS.md | 2 + .../common/plugins/agentPluginServiceImpl.ts | 174 ++++++++++++++++++ 3 files changed, 178 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 10afb3603743c..757a1701f658c 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -159,7 +159,7 @@ import { LanguageModelToolsConfirmationService } from './tools/languageModelTool import { LanguageModelToolsService, globalAutoApproveDescription } from './tools/languageModelToolsService.js'; import { IToolResultCompressor } from '../common/tools/toolResultCompressor.js'; import { ToolResultCompressorService } from './tools/toolResultCompressorService.js'; -import { AgentPluginService, ConfiguredAgentPluginDiscovery, ExtensionAgentPluginDiscovery, MarketplaceAgentPluginDiscovery } from '../common/plugins/agentPluginServiceImpl.js'; +import { AgentPluginService, ConfiguredAgentPluginDiscovery, CopilotCliAgentPluginDiscovery, ExtensionAgentPluginDiscovery, MarketplaceAgentPluginDiscovery } from '../common/plugins/agentPluginServiceImpl.js'; import { IAgentPluginRepositoryService } from '../common/plugins/agentPluginRepositoryService.js'; import { IPluginInstallService } from '../common/plugins/pluginInstallService.js'; import { IPluginMarketplaceService, PluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js'; @@ -2287,6 +2287,7 @@ registerEditorFeature(ChatPasteProvidersFeature); agentPluginDiscoveryRegistry.register(new SyncDescriptor(ConfiguredAgentPluginDiscovery)); agentPluginDiscoveryRegistry.register(new SyncDescriptor(MarketplaceAgentPluginDiscovery)); agentPluginDiscoveryRegistry.register(new SyncDescriptor(ExtensionAgentPluginDiscovery)); +agentPluginDiscoveryRegistry.register(new SyncDescriptor(CopilotCliAgentPluginDiscovery)); registerSingleton(IChatResponseResourceFileSystemProvider, ChatResponseResourceFileSystemProvider, InstantiationType.Delayed); registerSingleton(IChatTransferService, ChatTransferService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md b/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md index 74bd0ab92f62b..e5678431974a5 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md +++ b/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md @@ -84,6 +84,8 @@ Subclasses implement `_discoverPluginSources()` to determine *which* plugin URIs **MarketplaceAgentPluginDiscovery** — discovers plugins from `IPluginMarketplaceService.installedPlugins` and delegates to the install/repository services for on-disk availability. +**CopilotCliAgentPluginDiscovery** — discovers plugins installed by the Copilot CLI under `~/.copilot/installed-plugins///` (two levels deep; `_direct` is the marketplace segment for non-marketplace installs). Watches the deepest existing ancestor (down to the install root) and each marketplace bucket non-recursively so the first-ever install is detected without a reload. + ### Plugin Formats Three format adapters implement `IAgentPluginFormatAdapter`: diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index 23fb44746437e..b972305aed072 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -661,6 +661,180 @@ export class MarketplaceAgentPluginDiscovery extends AbstractAgentPluginDiscover } } +// --------------------------------------------------------------------------- +// Copilot CLI plugin discovery +// --------------------------------------------------------------------------- + +/** + * Directory under the Copilot CLI home where installed plugins are cached. + * Layout is two levels deep: `//`. Direct (non-marketplace) + * installs use the reserved marketplace segment `_direct`. + * + * See `src/plugins/manager.ts` in the copilot-agent-runtime repo. + */ +const COPILOT_CLI_INSTALLED_PLUGINS_DIR = '.copilot/installed-plugins'; + +/** + * Discovers plugins installed by the Copilot CLI under + * `~/.copilot/installed-plugins///`. Each leaf directory + * is treated as a plugin root, allowing CLI-installed plugins (both + * marketplace and direct) to surface in VS Code without a separate install. + */ +export class CopilotCliAgentPluginDiscovery extends AbstractAgentPluginDiscovery { + + constructor( + @IFileService fileService: IFileService, + @IPathService pathService: IPathService, + @ILogService logService: ILogService, + @IWorkspaceContextService workspaceContextService: IWorkspaceContextService, + @IDialogService private readonly _dialogService: IDialogService, + ) { + super(fileService, pathService, logService, workspaceContextService); + } + + public override start(enablementModel: IEnablementModel): void { + this._enablementModel = enablementModel; + const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0)); + + const watcherStore = this._register(new DisposableStore()); + const setupWatchers = async () => { + watcherStore.clear(); + if (this._store.isDisposed) { + return; + } + + const root = await this._getInstalledPluginsDir(); + + // Walk up to the deepest existing ancestor and watch each directory + // from there down. Non-recursive watchers fail if the target doesn't + // exist, so we need to watch an existing parent (e.g. ~/.copilot or + // userHome) to detect the first-ever plugin install. + const dirsToWatch: URI[] = []; + let candidate: URI | undefined = root; + while (candidate) { + dirsToWatch.unshift(candidate); + const parent = joinPath(candidate, '..'); + if (parent.toString() === candidate.toString()) { + break; + } + if (await this._pathExists(parent)) { + dirsToWatch.unshift(parent); + break; + } + candidate = parent; + } + + for (const dir of dirsToWatch) { + if (!(await this._pathExists(dir))) { + continue; + } + const watcher = this._fileService.createWatcher(dir, { recursive: false, excludes: [] }); + watcherStore.add(watcher); + watcherStore.add(watcher.onDidChange(() => { + scheduler.schedule(); + // Re-attach watchers in case directories appeared/disappeared. + setupWatchers().catch(() => { /* watchers are best-effort */ }); + })); + } + + // Watch each marketplace bucket non-recursively for plugin + // install/uninstall events. + let rootStat; + try { + rootStat = await this._fileService.resolve(root); + } catch { + return; + } + if (!rootStat.children) { + return; + } + for (const marketplaceDir of rootStat.children) { + if (!marketplaceDir.isDirectory) { + continue; + } + const watcher = this._fileService.createWatcher(marketplaceDir.resource, { recursive: false, excludes: [] }); + watcherStore.add(watcher); + watcherStore.add(watcher.onDidChange(() => scheduler.schedule())); + } + }; + + setupWatchers().catch(() => { /* watchers are best-effort */ }); + scheduler.schedule(); + } + + private async _getInstalledPluginsDir(): Promise { + const userHome = await this._pathService.userHome(); + return joinPath(userHome, COPILOT_CLI_INSTALLED_PLUGINS_DIR); + } + + protected override async _discoverPluginSources(): Promise { + const root = await this._getInstalledPluginsDir(); + + let rootStat; + try { + rootStat = await this._fileService.resolve(root); + } catch { + // Directory doesn't exist — Copilot CLI hasn't installed any plugins. + return []; + } + + if (!rootStat.isDirectory || !rootStat.children) { + return []; + } + + const sources: IPluginSource[] = []; + // Each immediate child is a marketplace bucket (e.g. `_direct`, + // ``); each grandchild is a plugin root. + for (const marketplaceDir of rootStat.children) { + if (!marketplaceDir.isDirectory) { + continue; + } + + let marketplaceStat; + try { + marketplaceStat = await this._fileService.resolve(marketplaceDir.resource); + } catch { + continue; + } + + if (!marketplaceStat.children) { + continue; + } + + for (const pluginDir of marketplaceStat.children) { + if (!pluginDir.isDirectory) { + continue; + } + sources.push({ + uri: pluginDir.resource, + fromMarketplace: undefined, + remove: () => this._promptRemove(pluginDir.resource), + }); + } + } + + return sources; + } + + private async _promptRemove(resource: URI): Promise { + const { confirmed } = await this._dialogService.confirm({ + message: localize('copilotCliPlugin.remove.confirm', "This plugin was installed by the Copilot CLI. Remove it from disk?"), + detail: localize('copilotCliPlugin.remove.detail', "The plugin directory '{0}' will be moved to the trash. You can reinstall it later via the Copilot CLI.", resource.fsPath), + primaryButton: localize('copilotCliPlugin.remove.primary', "Remove"), + }); + if (!confirmed) { + return; + } + + try { + await this._fileService.del(resource, { recursive: true, useTrash: true }); + this._enablementModel.remove(resource.toString()); + } catch (error) { + this._logService.error('[CopilotCliAgentPluginDiscovery] Failed to remove plugin', error); + } + } +} + // --------------------------------------------------------------------------- // Extension-contributed plugin discovery // --------------------------------------------------------------------------- From e8d8f5f391fbb19eb62fc758d920a2277169579b Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 6 May 2026 17:25:33 -0400 Subject: [PATCH 18/34] Chat terminal: detect sensitive prompts and surface focus-terminal UI (#314826) --- .../browser/tools/monitoring/outputMonitor.ts | 46 ++++- .../browser/tools/runInTerminalTool.ts | 159 +++++++++++++++++- .../test/browser/outputMonitor.test.ts | 37 +++- .../runInTerminalTool.test.ts | 15 +- 4 files changed, 242 insertions(+), 15 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index af3c934a1eb27..3de8606057f21 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -20,6 +20,14 @@ export interface IOutputMonitor extends Disposable { readonly onDidFinishCommand: Event; readonly onDidDetectInputNeeded: Event; + /** + * Fires when the terminal is detected to be waiting for sensitive input + * (e.g. a password, passphrase, token, secret or verification code). This + * is fired *instead of* {@link onDidDetectInputNeeded} so callers can show + * UI that focuses the terminal rather than routing the prompt through the + * agent. + */ + readonly onDidDetectSensitiveInputNeeded: Event; } export interface IOutputMonitorTelemetryCounters { @@ -98,6 +106,9 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { private readonly _onDidDetectInputNeeded = this._register(new Emitter()); readonly onDidDetectInputNeeded: Event = this._onDidDetectInputNeeded.event; + private readonly _onDidDetectSensitiveInputNeeded = this._register(new Emitter()); + readonly onDidDetectSensitiveInputNeeded: Event = this._onDidDetectSensitiveInputNeeded.event; + private _asyncMode = false; private _command = ''; private _invocationContext: IToolInvocationContext | undefined; @@ -374,8 +385,13 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { // (passwords, [Y/n], etc.) and signal the agent to handle via send_to_terminal. if (this._asyncMode) { if (detectsInputRequiredPattern(outputLastLine)) { - this._logService.trace('OutputMonitor: Async mode - input-required pattern detected, signaling agent'); - this._onDidDetectInputNeeded.fire(); + if (detectsSensitiveInputPrompt(outputLastLine)) { + this._logService.trace('OutputMonitor: Async mode - sensitive input prompt detected, signaling sensitive UI'); + this._onDidDetectSensitiveInputNeeded.fire(); + } else { + this._logService.trace('OutputMonitor: Async mode - input-required pattern detected, signaling agent'); + this._onDidDetectInputNeeded.fire(); + } } this._cleanupIdleInputListener(); return { shouldContinuePolling: false, output }; @@ -384,10 +400,17 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { // Use regex-based detection for input-required patterns (passwords, [Y/n], etc.) // In foreground mode, fire the event so the race in runInTerminalTool can pick it // up and return control to the agent (which uses send_to_terminal to provide input). - // No elicitation UI is shown — the agent handles it autonomously. + // For sensitive prompts (passwords, secrets, OTPs, …) we instead fire a separate + // event so the tool can show a confirmation dialog that focuses the terminal — + // the secret must never be routed through the model. if (detectsInputRequiredPattern(outputLastLine)) { - this._logService.trace('OutputMonitor: Input-required pattern detected, signaling agent'); - this._onDidDetectInputNeeded.fire(); + if (detectsSensitiveInputPrompt(outputLastLine)) { + this._logService.trace('OutputMonitor: Sensitive input prompt detected, signaling sensitive UI'); + this._onDidDetectSensitiveInputNeeded.fire(); + } else { + this._logService.trace('OutputMonitor: Input-required pattern detected, signaling agent'); + this._onDidDetectInputNeeded.fire(); + } this._cleanupIdleInputListener(); return { shouldContinuePolling: false, output }; } @@ -541,10 +564,21 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { } private _isSensitivePrompt(prompt: string): boolean { - return /(password|passphrase|token|api\s*key|secret)/i.test(prompt); + return detectsSensitiveInputPrompt(prompt); } } +/** + * Returns true when the terminal's last visible line looks like a prompt for + * a sensitive secret (password, passphrase, token, API key, OTP, etc.). Used + * to short-circuit the normal "input needed → return to agent" flow so that + * the secret is never routed through the model — instead the user is asked + * via UI to focus the terminal and type the secret directly. + */ +export function detectsSensitiveInputPrompt(cursorLine: string): boolean { + return /(password|passphrase|token|api\s*key|secret|verification code|otp\b|one[\s-]?time (?:code|password)|2fa|mfa|pin\s*(?:code|number)?[: ]?\s*$|authentication code)/i.test(cursorLine); +} + export function matchTerminalPromptOption(options: readonly string[], suggestedOption: string): { option: string | undefined; index: number } { const normalize = (value: string) => value.replace(/['"`]/g, '').trim().replace(/[.,:;]+$/, ''); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 78fd504195ff8..4d458c25718f9 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -10,7 +10,7 @@ import { Codicon } from '../../../../../../base/common/codicons.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; import { Event } from '../../../../../../base/common/event.js'; import { escapeMarkdownSyntaxTokens, MarkdownString, type IMarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { Disposable, DisposableMap, DisposableStore, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { getMediaMime } from '../../../../../../base/common/mime.js'; import { basename, posix, win32 } from '../../../../../../base/common/path.js'; @@ -1084,6 +1084,120 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { : new MarkdownString(localize('runInTerminal.unsandboxed.autoRetry', "Run `{0}` command outside the sandbox?", shellType)); } + /** + * Surface a confirmation dialog when the terminal is detected to be waiting + * for sensitive input (password, passphrase, OTP, …). Sensitive prompts must + * never be routed through the model — the user types the secret directly + * into the terminal. The "Focus terminal" action reveals and focuses the + * terminal; the "Cancel" action cancels the running command. + * + * Returns a disposable that hides any pending elicitation. The handler + * itself dedupes concurrent elicitations so repeated polling cycles don't + * spam the chat session. + */ + private _registerSensitiveInputElicitation( + chatSessionResource: URI | undefined, + terminalInstance: ITerminalInstance, + outputMonitor: { onDidDetectSensitiveInputNeeded: Event }, + cancelExecution: () => void, + onAutoCancelled?: () => void, + ): IDisposable { + const store = new DisposableStore(); + let pending: { hide: () => void } | undefined; + let autoCancelled = false; + + store.add(outputMonitor.onDidDetectSensitiveInputNeeded(() => { + if (pending || autoCancelled) { + return; + } + const isAutoApproved = chatSessionResource && isSessionAutoApproveLevel(chatSessionResource, this._configurationService, this._chatWidgetService, this._chatService); + const chatModel = chatSessionResource && this._chatService.getSession(chatSessionResource); + if (isAutoApproved) { + // Autopilot / auto-approve: no human is in the loop to type the + // secret, and the terminal can't reliably be focused after the + // tool returns. Cancel the command and let the caller emit a + // steering note that tells the agent the user is unavailable. + // We also surface a small dismiss-only chat part so the user + // can see what happened even if the agent doesn't follow up + // with a message of its own. + autoCancelled = true; + if (chatModel instanceof ChatModel) { + const request = chatModel.getRequests().at(-1); + if (request) { + const infoPart = new ChatElicitationRequestPart( + new MarkdownString(localize('runInTerminal.sensitiveInput.autoCancelTitle', "Terminal command cancelled — sensitive input required")), + new MarkdownString(localize('runInTerminal.sensitiveInput.autoCancelMessage', "The terminal command was prompting for a password or other secret. Auto-approve / autopilot mode cannot safely supply secrets, so the command was cancelled. Run the command interactively if you want to provide the secret.")), + '', + localize('runInTerminal.sensitiveInput.dismiss', "Dismiss"), + '', + async () => { infoPart.hide(); return ElicitationState.Accepted; }, + async () => { infoPart.hide(); return ElicitationState.Rejected; }, + undefined, + undefined, + undefined, + undefined, + ); + chatModel.acceptResponseProgress(request, infoPart); + } + } + onAutoCancelled?.(); + cancelExecution(); + return; + } + if (!(chatModel instanceof ChatModel)) { + // No chat surface to attach to — fall back to focusing the + // terminal directly so the user is at least not left blocked. + this._terminalService.setActiveInstance(terminalInstance); + this._terminalService.revealTerminal(terminalInstance, true).catch(() => { }); + terminalInstance.focus(); + return; + } + const request = chatModel.getRequests().at(-1); + if (!request) { + return; + } + + const part = new ChatElicitationRequestPart( + new MarkdownString(localize('runInTerminal.sensitiveInput.title', "Terminal is waiting for sensitive input")), + new MarkdownString(localize('runInTerminal.sensitiveInput.message', "The terminal command appears to be prompting for a password or other sensitive value. Focus the terminal to type it directly — secrets must not be sent through chat.")), + '', + localize('runInTerminal.sensitiveInput.focus', "Focus Terminal"), + localize('runInTerminal.sensitiveInput.cancel', "Cancel Command"), + async () => { + pending = undefined; + part.hide(); + try { + this._terminalService.setActiveInstance(terminalInstance); + await this._terminalService.revealTerminal(terminalInstance, true); + terminalInstance.focus(); + } catch (err) { + this._logService.warn(`RunInTerminalTool: failed to reveal terminal for sensitive input`, err); + } + return ElicitationState.Accepted; + }, + async () => { + pending = undefined; + part.hide(); + cancelExecution(); + return ElicitationState.Rejected; + }, + undefined, + undefined, + () => { pending = undefined; }, + undefined, + ); + + pending = part; + chatModel.acceptResponseProgress(request, part); + // Intentionally do NOT register a disposable that hides the part on store + // dispose: the elicitation must persist past the tool call returning so the + // user can still focus the terminal (and type their secret) after the + // agent has surrendered its turn. The part hides itself on accept/reject. + })); + + return store; + } + private _acceptAutomaticUnsandboxRetryToolInvocationUpdate(sessionResource: URI | undefined, toolCallId: string, toolSpecificData: IChatTerminalToolInvocationData, isComplete: boolean, toolResultMessage?: string | IMarkdownString): void { const chatModel = sessionResource && this._chatService.getSession(sessionResource); if (!(chatModel instanceof ChatModel)) { @@ -1271,6 +1385,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { let altBufferResult: IToolResult | undefined; let didTimeout = false; let didInputNeeded = false; + let didSensitiveAutoCancelled = false; // Covers both terminals that start as background (persistentSession) and // foreground terminals that later move to background (timeout/continue-in-bg). let isBackgroundExecution = executionOptions.persistentSession; @@ -1434,6 +1549,30 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { // Also race on output monitor input-needed so that interactive prompts // return output to the agent early instead of waiting for timeout. const raceCleanup = new DisposableStore(); + // Sensitive prompts (passwords, OTPs, …) must never reach the model. + // Show a confirmation dialog that focuses the terminal so the user + // types the secret directly. The race is *not* resolved by sensitive + // prompts — the running command keeps waiting for user input until + // either it completes (executionPromise wins) or the user cancels + // it from the dialog (which cancels execution and also makes + // executionPromise resolve). This means we never hand a secret + // prompt back to the model; the user is always in control. + // + // outputMonitor is created later inside `onDidCreateStartMarker`, + // so we must wait on `startMarkerPromise` before registering the + // listener — otherwise outputMonitor is still undefined here and + // the sensitive event never reaches us. + startMarkerPromise.then(() => { + if (outputMonitor && !raceCleanup.isDisposed) { + raceCleanup.add(this._registerSensitiveInputElicitation( + chatSessionResource, + toolTerminal.instance, + outputMonitor, + () => executeCancellation.cancel(), + () => { didSensitiveAutoCancelled = true; }, + )); + } + }); const raceCandidates: Promise<{ type: 'completed'; result: ITerminalExecuteStrategyResult } | { type: 'background' } | { type: 'timeout' } | { type: 'inputNeeded' }>[] = [ executionPromise.then(result => ({ type: 'completed' as const, result })), continueInBackgroundPromise.then(() => ({ type: 'background' as const })), @@ -1731,7 +1870,9 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { resultText.push(`Note: This terminal execution was moved to the background using the ID ${termId}\n`); } } - if (didInputNeeded) { + if (didSensitiveAutoCancelled) { + resultText.push(`Note: The command in terminal ID ${termId} was prompting for a password, passphrase, or other secret. The user is unavailable (auto-approve / autopilot mode is on, so no human can focus the terminal to type a secret) and the command has been cancelled. Stop, do NOT retry the command, do NOT call ${TerminalToolId.SendToTerminal}, and do NOT call vscode_askQuestions for the secret. Tell the user to run the command interactively when they are available.\n\n`); + } else if (didInputNeeded) { resultText.push(`Note: The command is running in terminal ID ${termId} and may be waiting for input.\n${this._buildInputNeededSteeringText(chatSessionResource, termId, /*mentionTimeout*/ false)}\n\n`); } else if (didTimeout && timeoutValue !== undefined && timeoutValue > 0) { const notificationHint = shouldSendNotifications @@ -2259,6 +2400,20 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { store.add(outputMonitor); outputMonitor.continueMonitoringAsync(bgCts.token); + // Sensitive prompts (passwords, OTPs, …) detected while the command runs + // in the background must not generate a steering message — the secret + // must never reach the model. Show a confirmation dialog that focuses + // the terminal so the user can type the secret directly. + store.add(this._registerSensitiveInputElicitation( + chatSessionResource, + terminalInstance, + outputMonitor, + () => { + const execution = RunInTerminalTool._activeExecutions.get(termId); + execution?.dispose(); + }, + )); + // When the output monitor detects the terminal is waiting for input, // send a steering message so the agent handles it via send_to_terminal. store.add(outputMonitor.onDidDetectInputNeeded(() => { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts index 3781814f337a6..af9dc182dcb97 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { detectsGenericPressAnyKeyPattern, detectsHighConfidenceInputPattern, detectsInputRequiredPattern, detectsNonInteractiveHelpPattern, detectsVSCodeTaskFinishMessage, getLastLine, matchTerminalPromptOption, OutputMonitor } from '../../browser/tools/monitoring/outputMonitor.js'; +import { detectsGenericPressAnyKeyPattern, detectsHighConfidenceInputPattern, detectsInputRequiredPattern, detectsNonInteractiveHelpPattern, detectsSensitiveInputPrompt, detectsVSCodeTaskFinishMessage, getLastLine, matchTerminalPromptOption, OutputMonitor } from '../../browser/tools/monitoring/outputMonitor.js'; import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IExecution, IPollingResult, OutputMonitorState } from '../../browser/tools/monitoring/types.js'; @@ -233,6 +233,41 @@ suite('OutputMonitor', () => { }); }); + test('sensitive prompt fires onDidDetectSensitiveInputNeeded and not onDidDetectInputNeeded', async () => { + return runWithFakedTimers({}, async () => { + execution.getOutput = () => 'Password: '; + monitor = store.add(instantiationService.createInstance(OutputMonitor, execution, undefined, createTestContext('1'), cts.token, 'test command')); + + let inputNeededFired = false; + let sensitiveFired = false; + store.add(monitor.onDidDetectInputNeeded(() => { inputNeededFired = true; })); + store.add(monitor.onDidDetectSensitiveInputNeeded(() => { sensitiveFired = true; })); + + await Event.toPromise(monitor.onDidFinishCommand); + + assert.strictEqual(sensitiveFired, true, 'onDidDetectSensitiveInputNeeded should fire for sensitive prompts'); + assert.strictEqual(inputNeededFired, false, 'onDidDetectInputNeeded must not fire for sensitive prompts so the secret is not routed to the agent'); + }); + }); + + test('detectsSensitiveInputPrompt matches common secret prompts', () => { + assert.strictEqual(detectsSensitiveInputPrompt('Password: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('[sudo] password for jdoe: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('Passphrase for key /Users/foo/.ssh/id_rsa: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('Enter your API key: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('Token: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('Verification code: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('Enter OTP: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('One-time code: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('Enter your 2FA code: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('Enter MFA code: '), true); + + assert.strictEqual(detectsSensitiveInputPrompt('Continue? (y/n) '), false); + assert.strictEqual(detectsSensitiveInputPrompt('Press any key to continue...'), false); + assert.strictEqual(detectsSensitiveInputPrompt('Enter your name: '), false); + assert.strictEqual(detectsSensitiveInputPrompt('package name: (test_npm_init) '), false); + }); + test('extended timeout with isActive fires onDidDetectInputNeeded', async () => { return runWithFakedTimers({}, async () => { // Simulate a process that stays active with output that doesn't diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index 4e496253093d1..70d3fb3721e6b 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -1980,16 +1980,17 @@ suite('RunInTerminalTool', () => { const outputMonitor = { onDidDetectInputNeeded: inputNeededEmitter.event, + onDidDetectSensitiveInputNeeded: Event.None, continueMonitoringAsync: () => { }, dispose: () => { }, - } as unknown as { onDidDetectInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }; + } as unknown as { onDidDetectInputNeeded: Event; onDidDetectSensitiveInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }; (runInTerminalTool.constructor as unknown as { _activeExecutions: Map })._activeExecutions.set(termId, { getOutput: () => output, }); // eslint-disable-next-line @typescript-eslint/naming-convention - (runInTerminalTool as unknown as { _registerCompletionNotification: (terminal: ITerminalInstance, termId: string, session: URI, commandName: string, outputMonitor: { onDidDetectInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }) => void }) + (runInTerminalTool as unknown as { _registerCompletionNotification: (terminal: ITerminalInstance, termId: string, session: URI, commandName: string, outputMonitor: { onDidDetectInputNeeded: Event; onDidDetectSensitiveInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }) => void }) ._registerCompletionNotification(terminalInstance, termId, sessionResource, 'npm init', outputMonitor); inputNeededEmitter.fire(); @@ -2021,9 +2022,10 @@ suite('RunInTerminalTool', () => { const outputMonitor = { onDidDetectInputNeeded: inputNeededEmitter.event, + onDidDetectSensitiveInputNeeded: Event.None, continueMonitoringAsync: () => { }, dispose: () => { }, - } as unknown as { onDidDetectInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }; + } as unknown as { onDidDetectInputNeeded: Event; onDidDetectSensitiveInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }; (runInTerminalTool.constructor as unknown as { _activeExecutions: Map })._activeExecutions.set(termId, { getOutput: () => output, @@ -2034,7 +2036,7 @@ suite('RunInTerminalTool', () => { // monitor's first re-detection of the same prompt must not fire a steering // message that would yield the agent's in-flight `send_to_terminal` reply. // eslint-disable-next-line @typescript-eslint/naming-convention - (runInTerminalTool as unknown as { _registerCompletionNotification: (terminal: ITerminalInstance, termId: string, session: URI, commandName: string, outputMonitor: { onDidDetectInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }, alreadyNotifiedInputNeededOutput?: string) => void }) + (runInTerminalTool as unknown as { _registerCompletionNotification: (terminal: ITerminalInstance, termId: string, session: URI, commandName: string, outputMonitor: { onDidDetectInputNeeded: Event; onDidDetectSensitiveInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }, alreadyNotifiedInputNeededOutput?: string) => void }) ._registerCompletionNotification(terminalInstance, termId, sessionResource, 'mkdir -p foo && cd foo && npm init', outputMonitor, output); inputNeededEmitter.fire(); @@ -2066,9 +2068,10 @@ suite('RunInTerminalTool', () => { const outputMonitor = { onDidDetectInputNeeded: inputNeededEmitter.event, + onDidDetectSensitiveInputNeeded: Event.None, continueMonitoringAsync: () => { }, dispose: () => { }, - } as unknown as { onDidDetectInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }; + } as unknown as { onDidDetectInputNeeded: Event; onDidDetectSensitiveInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }; // Set up fg terminal association and active execution runInTerminalTool.sessionTerminalAssociations.set(sessionResource, { @@ -2082,7 +2085,7 @@ suite('RunInTerminalTool', () => { }); // eslint-disable-next-line @typescript-eslint/naming-convention - (runInTerminalTool as unknown as { _registerCompletionNotification: (terminal: ITerminalInstance, termId: string, session: URI, commandName: string, outputMonitor: { onDidDetectInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }) => void }) + (runInTerminalTool as unknown as { _registerCompletionNotification: (terminal: ITerminalInstance, termId: string, session: URI, commandName: string, outputMonitor: { onDidDetectInputNeeded: Event; onDidDetectSensitiveInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }) => void }) ._registerCompletionNotification(terminalInstance, termId, sessionResource, 'ssh host', outputMonitor); // Fire inputNeeded — this simulates the output monitor detecting a prompt From 1cd7c1c0975544fcdb074575ad180b1277bb5cad Mon Sep 17 00:00:00 2001 From: Paul Date: Wed, 6 May 2026 14:25:59 -0700 Subject: [PATCH 19/34] Reasoning effort and context picker updates (#314823) --- .../vscode-node/languageModelAccess.ts | 139 +++++++++++++----- .../src/extension/intents/node/agentIntent.ts | 10 +- .../browser/widget/input/chatModelPicker.ts | 117 ++++++++++++--- .../widget/input/chatModelPicker.test.ts | 25 +++- 4 files changed, 232 insertions(+), 59 deletions(-) diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts index 54ede56906610..cecd1b8eb400d 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts @@ -16,7 +16,7 @@ import { IEndpointProvider } from '../../../platform/endpoint/common/endpointPro import { CustomDataPartMimeTypes } from '../../../platform/endpoint/common/endpointTypes'; import { ModelAliasRegistry } from '../../../platform/endpoint/common/modelAliasRegistry'; import { encodeStatefulMarker } from '../../../platform/endpoint/common/statefulMarkerContainer'; -import { isGeminiFamily } from '../../../platform/endpoint/common/chatModelCapabilities'; +import { isAnthropicFamily, isGeminiFamily } from '../../../platform/endpoint/common/chatModelCapabilities'; import { AutoChatEndpoint } from '../../../platform/endpoint/node/autoChatEndpoint'; import { IAutomodeService } from '../../../platform/endpoint/node/automodeService'; import { IEnvService, isScenarioAutomation } from '../../../platform/env/common/envService'; @@ -54,13 +54,39 @@ const experimentalAutoModelHintMarkers = ['minimax', 'mp3yn0h7', 'yaqq2gxh']; * Builds a configurationSchema for the model picker based on the endpoint's supported capabilities. * Models that support reasoning_effort get a "Thinking Effort" dropdown in the model picker UI. */ -function buildConfigurationSchema(endpoint: IChatEndpoint): { configurationSchema?: vscode.LanguageModelConfigurationSchema } { - const effortLevels = endpoint.supportsReasoningEffort; - if (!effortLevels || effortLevels.length <= 1) { - return {}; +/** + * Returns the available context size options for a model, or undefined if the + * model does not support configurable context sizes. + * + * For opus models with a large context window (>= 900K tokens), offers a + * standard 200K option and the model's full context size. + */ +function getContextSizeOptions(endpoint: IChatEndpoint): { value: number; description: string; isDefault: boolean }[] | undefined { + const maxTokens = endpoint.modelMaxPromptTokens; + + // Claude Opus models with a large context window (~1M or more) get a 200K/full toggle + if (isAnthropicFamily(endpoint) && endpoint.family.startsWith('claude-opus') && maxTokens > 900_000) { + return [ + { value: 200_000, description: vscode.l10n.t('Standard context window'), isDefault: true }, + { value: maxTokens, description: vscode.l10n.t('Larger context window. Long conversations may incur significant costs'), isDefault: false }, + ]; } - // Auto model delegates to different backends, so don't expose effort picker + return undefined; +} + +function formatTokenCount(count: number): string { + if (count > 900_000) { + const value = Math.ceil(count / 1_000_000); + return `${value}M`; + } else if (count >= 1000) { + return `${Math.round(count / 1000)}K`; + } + return count.toString(); +} + +// Auto model delegates to different backends, so don't expose config pickers +function buildConfigurationSchema(endpoint: IChatEndpoint): { configurationSchema?: vscode.LanguageModelConfigurationSchema } { if (endpoint instanceof AutoChatEndpoint) { return {}; } @@ -70,37 +96,66 @@ function buildConfigurationSchema(endpoint: IChatEndpoint): { configurationSchem return {}; } - let defaultEffort: string | undefined; - if (family.startsWith('claude')) { - defaultEffort = effortLevels.includes('high') ? 'high' : undefined; - } else if (family.startsWith('gpt-')) { - defaultEffort = effortLevels.includes('medium') ? 'medium' : undefined; - } + const properties: Record = {}; + + // Reasoning effort config + const effortLevels = endpoint.supportsReasoningEffort; + if (effortLevels && effortLevels.length > 1) { + let defaultEffort: string | undefined; + if (family.startsWith('claude')) { + defaultEffort = effortLevels.includes('high') ? 'high' : undefined; + } else if (family.startsWith('gpt-')) { + defaultEffort = effortLevels.includes('medium') ? 'medium' : undefined; + } - return { - configurationSchema: { - properties: { - reasoningEffort: { - type: 'string', - title: vscode.l10n.t('Thinking Effort'), - enum: effortLevels, - enumItemLabels: effortLevels.map(level => level.charAt(0).toUpperCase() + level.slice(1)), - enumDescriptions: effortLevels.map(level => { - switch (level) { - case 'none': return vscode.l10n.t('No reasoning applied'); - case 'low': return vscode.l10n.t('Faster responses with less reasoning'); - case 'medium': return vscode.l10n.t('Balanced reasoning and speed'); - case 'high': return vscode.l10n.t('Greater reasoning depth but slower'); - case 'xhigh': return vscode.l10n.t('Maximum reasoning depth but slower'); - default: return level; - } - }), - default: defaultEffort, - group: 'navigation', + properties.reasoningEffort = { + type: 'string', + title: vscode.l10n.t('Thinking Effort'), + enum: effortLevels, + enumItemLabels: effortLevels.map(level => level.charAt(0).toUpperCase() + level.slice(1)), + enumDescriptions: effortLevels.map(level => { + switch (level) { + case 'none': return vscode.l10n.t('No reasoning applied'); + case 'low': return vscode.l10n.t('Faster responses with less reasoning'); + case 'medium': return vscode.l10n.t('Balanced reasoning and speed'); + case 'high': return vscode.l10n.t('Greater reasoning depth but slower'); + case 'xhigh': return vscode.l10n.t('Highest reasoning depth but slowest'); + default: return level; } - } - } - }; + }), + default: defaultEffort, + group: 'navigation', + }; + } + + // Context size config + const contextSizeOptions = getContextSizeOptions(endpoint); + if (contextSizeOptions) { + const defaultOption = contextSizeOptions.find(o => o.isDefault); + properties.contextSize = { + type: 'number', + title: vscode.l10n.t('Context Size'), + enum: contextSizeOptions.map(o => o.value), + enumItemLabels: contextSizeOptions.map(o => formatTokenCount(o.value)), + enumDescriptions: contextSizeOptions.map(o => o.description), + default: defaultOption?.value, + group: 'tokens', + }; + } + + if (Object.keys(properties).length === 0) { + return {}; + } + + return { configurationSchema: { properties } }; } export class LanguageModelAccess extends Disposable implements IExtensionContribution { @@ -202,7 +257,9 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib } seenFamilies.add(endpoint.family); - const sanitizedModelName = endpoint.name.replace(/\(Preview\)/g, '').trim(); + const sanitizedModelName = endpoint.name + .replace(/\([^)]*\bcontext\)/gi, '') + .trim(); let modelTooltip: string | undefined; if (endpoint.degradationReason) { modelTooltip = endpoint.degradationReason; @@ -255,7 +312,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib const model: vscode.LanguageModelChatInformation = { id: endpoint instanceof AutoChatEndpoint ? AutoChatEndpoint.pseudoModelId : endpoint.model, - name: endpoint instanceof AutoChatEndpoint ? 'Auto' : endpoint.name, + name: endpoint instanceof AutoChatEndpoint ? 'Auto' : sanitizedModelName, family: endpoint.family, tooltip: modelTooltip, pricing: endpoint instanceof AutoChatEndpoint ? undefined : (multiplier ?? (endpoint.tokenPricing ? formatPricingLabel(endpoint.tokenPricing) : undefined)), @@ -319,11 +376,17 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib progress: vscode.Progress, token: vscode.CancellationToken ): Promise { - const endpoint = await this._getEndpointForModel(model); + let endpoint = await this._getEndpointForModel(model); if (!endpoint) { throw new Error(`Endpoint not found for model ${model.id}`); } + // Apply context size override if configured + const contextSize = options.modelConfiguration?.contextSize; + if (typeof contextSize === 'number' && contextSize < endpoint.modelMaxPromptTokens) { + endpoint = endpoint.cloneWithTokenOverride(contextSize); + } + return this._lmWrapper.provideLanguageModelResponse(endpoint, messages, { ...options, modelOptions: options.modelOptions diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts index 7ac5d672259d9..013d28faead7f 100644 --- a/extensions/copilot/src/extension/intents/node/agentIntent.ts +++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts @@ -505,9 +505,15 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I throw new Error(`Setting github.copilot.${ConfigKey.Advanced.SummarizeAgentConversationHistoryThreshold.id} is too low`); } + // Apply context size override if configured by the user in the model picker + const configuredContextSize = this.request.modelConfiguration?.contextSize; + const effectiveMaxTokens = typeof configuredContextSize === 'number' && configuredContextSize < this.endpoint.modelMaxPromptTokens + ? configuredContextSize + : this.endpoint.modelMaxPromptTokens; + const baseBudget = Math.min( - this.configurationService.getConfig(ConfigKey.Advanced.SummarizeAgentConversationHistoryThreshold) ?? this.endpoint.modelMaxPromptTokens, - this.endpoint.modelMaxPromptTokens + this.configurationService.getConfig(ConfigKey.Advanced.SummarizeAgentConversationHistoryThreshold) ?? effectiveMaxTokens, + effectiveMaxTokens ); const useTruncation = this.endpoint.apiType === 'responses' && this.configurationService.getConfig(ConfigKey.Advanced.UseResponsesApiTruncation); const responsesCompactionContextManagementEnabled = isResponsesCompactionContextManagementEnabled(this.endpoint, this.configurationService, this.expService); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index 13519940b2892..ebaeaf89751ef 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -683,10 +683,12 @@ export class ModelPickerWidget extends Disposable { this._effortButton.setAttribute('aria-expanded', 'false'); this._effortButton.style.display = 'none'; - // Max tokens toggle button (conditionally visible) + // Context size button (conditionally visible) this._tokensButton = dom.append(this._domNode, dom.$('a.model-picker-section.model-picker-tokens')); this._tokensButton.tabIndex = 0; this._tokensButton.setAttribute('role', 'button'); + this._tokensButton.setAttribute('aria-haspopup', 'true'); + this._tokensButton.setAttribute('aria-expanded', 'false'); this._tokensButton.style.display = 'none'; this._badgeIcon = dom.$('span.model-picker-badge'); @@ -696,7 +698,7 @@ export class ModelPickerWidget extends Disposable { this._registerButtonAction(this._nameButton, () => this.show()); this._registerButtonAction(this._effortButton, () => this._showEffortPicker()); - this._registerButtonAction(this._tokensButton, () => this._cycleTokens()); + this._registerButtonAction(this._tokensButton, () => this._showTokensPicker()); // Managed hovers for effort and tokens buttons this._register(getBaseLayerHoverDelegate().setupManagedHover( @@ -893,7 +895,7 @@ export class ModelPickerWidget extends Disposable { : formatTokenCount(Number(tokensConfig.value)); dom.reset(this._tokensButton, dom.$('span.chat-input-picker-label', undefined, tokensLabel)); this._tokensButton.style.display = ''; - this._tokensButton.ariaLabel = localize('chat.modelPicker.tokensAriaLabel', "Max Tokens: {0}", tokensLabel); + this._tokensButton.ariaLabel = localize('chat.modelPicker.tokensAriaLabel', "Context Size: {0}", tokensLabel); } else if (this._tokensButton) { this._tokensButton.style.display = 'none'; } @@ -921,13 +923,22 @@ export class ModelPickerWidget extends Disposable { const modelIdentifier = this._selectedModel.identifier; const enumValues = config.schema.enum ?? []; const enumItemLabels = config.schema.enumItemLabels; - const items: IActionListItem[] = enumValues.map((value: unknown, index: number) => { + + const items: IActionListItem[] = [ + { + kind: ActionListItemKind.Header, + label: localize('chat.effort.header', "Thinking Effort"), + } + ]; + + for (let index = 0; index < enumValues.length; index++) { + const value = enumValues[index]; const label = enumItemLabels?.[index] ?? String(value); const isDefault = value === config.schema.default; const displayLabel = isDefault ? localize('models.effortDefault', "{0} (default)", label) : label; - return { + items.push({ item: { id: `effort.${value}`, enabled: true, @@ -944,10 +955,11 @@ export class ModelPickerWidget extends Disposable { }, kind: ActionListItemKind.Action, label: displayLabel, + description: config.schema.enumDescriptions?.[index], group: { title: '', icon: ThemeIcon.fromId(config.value === value ? Codicon.check.id : Codicon.blank.id) }, hideIcon: false, - }; - }); + }); + } const previouslyFocusedElement = dom.getActiveElement(); const delegate = { @@ -983,20 +995,88 @@ export class ModelPickerWidget extends Disposable { ); } - private _cycleTokens(): void { + private _showTokensPicker(): void { if (this._domNode?.classList.contains('disabled')) { return; } const config = this._getConfigProperty('tokens'); - if (!config || !this._selectedModel) { + if (!config || !this._tokensButton || !this._selectedModel) { return; } + + const modelIdentifier = this._selectedModel.identifier; const enumValues = config.schema.enum ?? []; - const currentIndex = enumValues.indexOf(config.value); - const nextIndex = (currentIndex + 1) % enumValues.length; - this._languageModelsService.setModelConfiguration( - this._selectedModel.identifier, - { [config.key]: enumValues[nextIndex] } + const enumItemLabels = config.schema.enumItemLabels; + + const items: IActionListItem[] = [ + { + kind: ActionListItemKind.Header, + label: localize('chat.tokens.header', "Context Size"), + } + ]; + + for (let index = 0; index < enumValues.length; index++) { + const value = enumValues[index]; + const label = enumItemLabels?.[index] ?? formatTokenCount(Number(value)); + const isDefault = value === config.schema.default; + const displayLabel = isDefault + ? localize('models.tokensDefault', "{0} (default)", label) + : label; + const description = config.schema.enumDescriptions?.[index]; + items.push({ + item: { + id: `tokens.${value}`, + enabled: true, + checked: config.value === value, + class: undefined, + tooltip: description ?? '', + label: displayLabel, + run: () => { + this._languageModelsService.setModelConfiguration( + modelIdentifier, + { [config.key]: value } + ); + } + }, + kind: ActionListItemKind.Action, + label: displayLabel, + description, + group: { title: '', icon: ThemeIcon.fromId(config.value === value ? Codicon.check.id : Codicon.blank.id) }, + hideIcon: false, + }); + } + + const previouslyFocusedElement = dom.getActiveElement(); + const delegate = { + onSelect: (action: IActionWidgetDropdownAction) => { + this._actionWidgetService.hide(); + action.run(); + }, + onHide: () => { + this._tokensButton?.setAttribute('aria-expanded', 'false'); + if (dom.isHTMLElement(previouslyFocusedElement)) { + previouslyFocusedElement.focus(); + } + } + }; + + this._tokensButton.setAttribute('aria-expanded', 'true'); + + this._actionWidgetService.show( + 'ChatModelTokensPicker', + false, + items, + delegate, + this._tokensButton, + undefined, + [], + { + isChecked(element: IActionListItem) { + return element.kind === ActionListItemKind.Action ? !!element?.item?.checked : undefined; + }, + getRole: () => 'menuitemradio' as const, + getWidgetRole: () => 'menu' as const, + } ); } } @@ -1038,11 +1118,12 @@ function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentifier): M } -function formatTokenCount(count: number): string { - if (count >= 1000000) { - return `${(count / 1000000).toFixed(1)}M`; +export function formatTokenCount(count: number): string { + if (count > 900_000) { + const value = Math.ceil(count / 1_000_000); + return `${value}M`; } else if (count >= 1000) { - return `${(count / 1000).toFixed(0)}K`; + return `${Math.round(count / 1000)}K`; } return count.toString(); } diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts index 011896bad5715..2da5b6130867a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts @@ -11,7 +11,7 @@ import { MarkdownString } from '../../../../../../../base/common/htmlContent.js' import { ActionListItemKind, IActionListItem } from '../../../../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetDropdownAction } from '../../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { StateType } from '../../../../../../../platform/update/common/update.js'; -import { buildModelPickerItems, getModelPickerAccessibilityProvider } from '../../../../browser/widget/input/chatModelPicker.js'; +import { buildModelPickerItems, formatTokenCount, getModelPickerAccessibilityProvider } from '../../../../browser/widget/input/chatModelPicker.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService, IModelControlEntry } from '../../../../common/languageModels.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../../../services/chat/common/chatEntitlementService.js'; @@ -809,3 +809,26 @@ suite('buildModelPickerItems', () => { }); }); +suite('formatTokenCount', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('returns M for counts above 900K', () => { + assert.strictEqual(formatTokenCount(1_000_000), '1M'); + assert.strictEqual(formatTokenCount(935_997), '1M'); + assert.strictEqual(formatTokenCount(1_500_000), '2M'); + assert.strictEqual(formatTokenCount(2_000_000), '2M'); + }); + + test('returns K for counts between 1000 and 900K', () => { + assert.strictEqual(formatTokenCount(200_000), '200K'); + assert.strictEqual(formatTokenCount(128_000), '128K'); + assert.strictEqual(formatTokenCount(1_000), '1K'); + assert.strictEqual(formatTokenCount(900_000), '900K'); + }); + + test('returns raw number for counts below 1000', () => { + assert.strictEqual(formatTokenCount(500), '500'); + assert.strictEqual(formatTokenCount(0), '0'); + }); +}); + From c498c28d7a9e89b50b21ba0772d9bea68b90b6c5 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 6 May 2026 17:26:23 -0400 Subject: [PATCH 20/34] Strip NODE_OPTIONS in removeDangerousEnvVariables (#314847) NODE_OPTIONS can be used to inject arbitrary flags (--require, --inspect, etc.) into forked Node processes, which can cause crashes and unexpected behavior in the extension host and other forked processes. Co-authored-by: Megan Rogge --- src/vs/base/common/processes.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/vs/base/common/processes.ts b/src/vs/base/common/processes.ts index 2eea4cf091ace..54236c43619ca 100644 --- a/src/vs/base/common/processes.ts +++ b/src/vs/base/common/processes.ts @@ -140,6 +140,11 @@ export function removeDangerousEnvVariables(env: IProcessEnvironment | undefined // See https://github.com/microsoft/vscode/issues/130072 delete env['DEBUG']; + // Unset `NODE_OPTIONS`, as it can be used to inject arbitrary flags + // (e.g. `--require`, `--inspect`) into forked Node processes, which + // has caused crashes and unexpected behavior. + delete env['NODE_OPTIONS']; + if (isLinux) { // Unset `LD_PRELOAD`, as it might lead to process crashes // See https://github.com/microsoft/vscode/issues/134177 From 28704d8c988226ecad320bbd11604ea27d64cf36 Mon Sep 17 00:00:00 2001 From: JeffreyCA Date: Wed, 6 May 2026 14:32:48 -0700 Subject: [PATCH 21/34] Integrated terminal - fix stale OSC 8 link hover tooltip issues (#309539) * Fix stale OSC 8 link hover tooltip when mouse cursor unhovers before trigger * Dismiss hover tooltip on leave/scroll * Address feedback from Copilot * Refactor and fix tests --- .../links/browser/terminalLinkManager.ts | 32 ++++- .../test/browser/terminalLinkManager.test.ts | 112 ++++++++++++++++++ 2 files changed, 138 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts index fb1496acdff50..3e7002c698085 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts @@ -98,11 +98,19 @@ export class TerminalLinkManager extends DisposableStore { let activeHoverDisposable: IDisposable | undefined; let activeTooltipScheduler: RunOnceScheduler | undefined; + let activeHoverListeners: DisposableStore | undefined; + const clearActiveLinkHover = () => { + activeHoverDisposable?.dispose(); + activeHoverDisposable = undefined; + activeTooltipScheduler?.dispose(); + activeTooltipScheduler = undefined; + activeHoverListeners?.dispose(); + activeHoverListeners = undefined; + }; this.add(toDisposable(() => { this._clearLinkProviders(); dispose(this._externalLinkProviders); - activeHoverDisposable?.dispose(); - activeTooltipScheduler?.dispose(); + clearActiveLinkHover(); })); this._xterm.options.linkHandler = { allowNonHttpProtocols: true, @@ -146,9 +154,7 @@ export class TerminalLinkManager extends DisposableStore { }); }, hover: (e, text, range) => { - activeHoverDisposable?.dispose(); - activeHoverDisposable = undefined; - activeTooltipScheduler?.dispose(); + clearActiveLinkHover(); activeTooltipScheduler = new RunOnceScheduler(() => { interface XtermWithCore extends Terminal { _core: IXtermCore; @@ -162,16 +168,30 @@ export class TerminalLinkManager extends DisposableStore { width: this._xterm.cols, height: this._xterm.rows }; + const hoverViewportY = this._xterm.buffer.active.viewportY; activeHoverDisposable = this._showHover({ - viewportRange: convertBufferRangeToViewport(range, this._xterm.buffer.active.viewportY), + viewportRange: convertBufferRangeToViewport(range, hoverViewportY), cellDimensions, terminalDimensions }, this._getLinkHoverString(text, text), undefined, (text) => this._xterm.options.linkHandler?.activate(e, text, range)); + activeHoverListeners = new DisposableStore(); + activeHoverListeners.add(this._xterm.onScroll(() => clearActiveLinkHover())); + activeHoverListeners.add(this._xterm.onRender(renderedRange => { + // Convert buffer range to viewport range and check if the + // rendered range intersects any row of the link + const viewportRange = convertBufferRangeToViewport(range, hoverViewportY); + if (viewportRange.start.y <= renderedRange.end && viewportRange.end.y >= renderedRange.start) { + clearActiveLinkHover(); + } + })); // Clear out scheduler until next hover event activeTooltipScheduler?.dispose(); activeTooltipScheduler = undefined; }, this._configurationService.getValue('workbench.hover.delay')); activeTooltipScheduler.schedule(); + }, + leave: () => { + clearActiveLinkHover(); } }; } diff --git a/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkManager.test.ts b/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkManager.test.ts index e4aa727cb6819..51a7e4fbb8126 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkManager.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkManager.test.ts @@ -22,10 +22,14 @@ import { ITerminalConfiguration, ITerminalProcessManager } from '../../../../ter import { TestViewDescriptorService } from '../../../../terminal/test/browser/xterm/xtermTerminal.test.js'; import { TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; import type { ILink, Terminal } from '@xterm/xterm'; +import { IXtermCore } from '../../../../terminal/browser/xterm-private.js'; import { TerminalLinkResolver } from '../../browser/terminalLinkResolver.js'; import { importAMDNodeModule } from '../../../../../../amdX.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { TestXtermLogger } from '../../../../../../platform/terminal/test/common/terminalTestHelpers.js'; +import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; +import { timeout } from '../../../../../../base/common/async.js'; +import { IDisposable } from '../../../../../../base/common/lifecycle.js'; const defaultTerminalConfig: Partial = { fontFamily: 'monospace', @@ -109,6 +113,114 @@ suite('TerminalLinkManager', () => { }); }); + // eslint-disable-next-line @typescript-eslint/naming-convention + type TestableLinkManager = { _showHover: (...args: unknown[]) => IDisposable | undefined }; + + function overrideXtermEvent(terminal: Terminal, eventName: string, handler: (listener: (e: T) => void) => IDisposable): IDisposable { + const originalDescriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(terminal), eventName); + Object.defineProperty(terminal, eventName, { value: handler, configurable: true }); + return { + dispose: () => { + if (originalDescriptor) { + Object.defineProperty(terminal, eventName, originalDescriptor); + } else { + delete (terminal as unknown as Record)[eventName]; + } + } + }; + } + + function mockXtermCoreRenderService(): IDisposable { + interface XtermWithCore extends Terminal { _core: IXtermCore } + const xtermWithCore = xterm as unknown as XtermWithCore; + const origRenderService = xtermWithCore._core?._renderService; + if (!xtermWithCore._core) { (xtermWithCore as XtermWithCore)._core = {} as IXtermCore; } + xtermWithCore._core._renderService = { dimensions: { css: { cell: { width: 8, height: 16 } } }, _renderer: {} }; + return { + dispose: () => { xtermWithCore._core._renderService = origRenderService!; } + }; + } + + suite('OSC 8 hover', () => { + test('should cancel delayed tooltip when leave happens before hover delay', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await configurationService.setUserConfiguration('workbench.hover.delay', 10); + const linkHandler = xterm.options.linkHandler; + if (!linkHandler?.hover || !linkHandler.leave) { + throw new Error('Expected linkHandler with hover/leave callbacks'); + } + let hoverShownCount = 0; + const testableLinkManager = linkManager as unknown as TestableLinkManager; + const originalShowHover = testableLinkManager._showHover; + testableLinkManager._showHover = () => { + hoverShownCount++; + return undefined; + }; + const range: Parameters[2] = { start: { x: 1, y: 1 }, end: { x: 10, y: 1 } }; + const event = new MouseEvent('mousemove'); + try { + linkHandler.hover(event, 'http://example.com', range); + linkHandler.leave(event, 'http://example.com', range); + await timeout(0); + strictEqual(hoverShownCount, 0); + } finally { + testableLinkManager._showHover = originalShowHover; + } + })); + + /** + * Triggers the hover callback, flushes the 0ms scheduler, then + * fires the given xterm event and asserts the hover was disposed. + */ + async function assertHoverDismissedOnEvent( + overrideEvent: (setFireEvent: (fn: () => void) => void) => IDisposable, + ): Promise { + await configurationService.setUserConfiguration('workbench.hover.delay', 0); + const linkHandler = xterm.options.linkHandler; + if (!linkHandler?.hover) { + throw new Error('Expected linkHandler with hover callback'); + } + let hoverDisposed = false; + const testableLinkManager = linkManager as unknown as TestableLinkManager; + const originalShowHover = testableLinkManager._showHover; + testableLinkManager._showHover = () => ({ + dispose: () => { hoverDisposed = true; } + }); + const renderServiceRestore = mockXtermCoreRenderService(); + const range: Parameters[2] = { start: { x: 1, y: 1 }, end: { x: 10, y: 1 } }; + let fireEvent: (() => void) | undefined; + const eventRestore = overrideEvent(fn => { fireEvent = fn; }); + try { + linkHandler.hover(new MouseEvent('mousemove'), 'http://example.com', range); + await timeout(0); + strictEqual(hoverDisposed, false); + fireEvent?.(); + strictEqual(hoverDisposed, true); + } finally { + eventRestore.dispose(); + renderServiceRestore.dispose(); + testableLinkManager._showHover = originalShowHover; + } + } + + test('should dismiss shown tooltip on scroll', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await assertHoverDismissedOnEvent(setFire => { + return overrideXtermEvent(xterm, 'onScroll', listener => { + setFire(() => listener(1)); + return { dispose: () => { } }; + }); + }); + })); + + test('should dismiss shown tooltip on render', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await assertHoverDismissedOnEvent(setFire => { + return overrideXtermEvent<{ start: number; end: number }>(xterm, 'onRender', listener => { + setFire(() => listener({ start: 0, end: 5 })); + return { dispose: () => { } }; + }); + }); + })); + }); + suite('getLinks and open recent link', () => { test('should return no links', async () => { const links = await linkManager.getLinks(); From ddaad838e62df67f6882b9d72fd25114f7932d09 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 6 May 2026 15:30:28 -0700 Subject: [PATCH 22/34] chat: use ILabelService for remote agent host names in permission UI (#314822) * chat: use ILabelService for remote agent host names in permission UI `AgentHostPermissionUiContribution` is registered for VS Code proper, but in VS Code we cannot depend directly on `IRemoteAgentHostService` since that service is only registered in the Agents window / sessions layer. Route the human-readable host name lookup through `ILabelService` instead. - `RemoteAgentHostService` now registers a per-host `ResourceLabelFormatter` for the `vscode-agent-host` scheme, with the entry name as the formatter's `workspaceSuffix`. The formatter is refreshed on entry add / name change and disposed on removal. - `AgentHostPermissionUiContribution` drops the `IRemoteAgentHostService` dependency and resolves the host name via `ILabelService.getHostLabel(AGENT_HOST_SCHEME, agentHostAuthority(address))`, falling back to the raw address when no formatter is registered (e.g. in regular VS Code workbench). - Updates the contribution and service tests accordingly. (Commit message generated by Copilot) * address review feedback --- .../browser/remoteAgentHostServiceImpl.ts | 63 ++++++++++++++++++- .../remoteAgentHostService.test.ts | 58 +++++++++++++++++ .../agentHostPermissionUiContribution.ts | 9 ++- .../agentHostPermissionUiContribution.test.ts | 50 +++++++-------- 4 files changed, 149 insertions(+), 31 deletions(-) diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts index aef822f98ad41..d2f890f15ad97 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts @@ -12,6 +12,7 @@ import { Disposable, DisposableStore, IDisposable } from '../../../base/common/l import { DeferredPromise, raceTimeout } from '../../../base/common/async.js'; import { ConfigurationTarget, IConfigurationService } from '../../configuration/common/configuration.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; +import { ILabelService } from '../../label/common/label.js'; import { ILogService } from '../../log/common/log.js'; import type { IAgentConnection } from '../common/agentService.js'; @@ -30,7 +31,7 @@ import { } from '../common/remoteAgentHostService.js'; import { RemoteAgentHostProtocolClient } from './remoteAgentHostProtocolClient.js'; import { WebSocketClientTransport } from './webSocketClientTransport.js'; -import { normalizeRemoteAgentHostAddress } from '../common/agentHostUri.js'; +import { AGENT_HOST_LABEL_FORMATTER, AGENT_HOST_SCHEME, agentHostAuthority, normalizeRemoteAgentHostAddress } from '../common/agentHostUri.js'; import { isDefined } from '../../../base/common/types.js'; import { PROTOCOL_VERSION } from '../common/state/protocol/version/registry.js'; @@ -70,11 +71,19 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo private readonly _reconnectTimeouts = new Map>(); /** Current reconnect attempt count per address for exponential backoff. */ private readonly _reconnectAttempts = new Map(); + /** + * Per-address {@link ILabelService} formatter handles for the + * {@link AGENT_HOST_SCHEME}. The formatter advertises the entry's + * human-readable name as the host label so any UI looking up the host + * label for an agent host URI gets the friendly name. + */ + private readonly _labelFormatters = new Map(); constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @ILogService private readonly _logService: ILogService, + @ILabelService private readonly _labelService: ILabelService, ) { super(); @@ -233,6 +242,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo this._entries.set(address, connEntry); this._names.set(address, entry.name); this._registeredEntries.set(address, entry); + this._updateHostLabelFormatter(address, entry.name); if (entry.connectionToken) { this._tokens.set(address, entry.connectionToken); } @@ -275,6 +285,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo this._names.delete(normalized); this._tokens.delete(normalized); this._registeredEntries.delete(normalized); + this._clearHostLabelFormatter(normalized); this._cancelReconnect(normalized); this._reconnectAttempts.delete(normalized); this._removeConnection(normalized); @@ -300,6 +311,15 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo this._names.clear(); this._tokens.clear(); this._reconnectAttempts.clear(); + // Drop label formatters for entries no longer represented by an + // active connection or a dynamically registered entry. Connections + // added via {@link addManagedConnection} (e.g. tunnels) live outside + // the configured-entries set and must keep their formatter. + for (const address of [...this._labelFormatters.keys()]) { + if (!this._registeredEntries.has(address)) { + this._clearHostLabelFormatter(address); + } + } return; } @@ -317,11 +337,20 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo for (const { entry, address } of entriesWithAddress) { this._names.set(address, entry.name); this._tokens.set(address, entry.connectionToken); + this._updateHostLabelFormatter(address, entry.name); if (this._entries.has(address) && oldNames.get(address) !== entry.name) { namesChanged = true; } } + // Drop formatters for addresses that are no longer configured and + // not dynamically registered. + for (const address of [...this._labelFormatters.keys()]) { + if (!desired.has(address) && !this._registeredEntries.has(address)) { + this._clearHostLabelFormatter(address); + } + } + // Remove connections no longer in the setting for (const address of [...this._entries.keys()]) { if (!desired.has(address)) { @@ -565,6 +594,34 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo void wait.error(err); } + /** + * Register (or re-register) the {@link AGENT_HOST_SCHEME} label formatter + * for the given address so that {@link ILabelService.getHostLabel} resolves + * to the entry's human-readable name. Called when an entry is added or its + * name changes. + */ + private _updateHostLabelFormatter(address: string, name: string): void { + this._clearHostLabelFormatter(address); + const handle = this._labelService.registerFormatter({ + scheme: AGENT_HOST_SCHEME, + authority: agentHostAuthority(address), + priority: true, + formatting: { + ...AGENT_HOST_LABEL_FORMATTER.formatting, + workspaceSuffix: name, + }, + }); + this._labelFormatters.set(address, handle); + } + + private _clearHostLabelFormatter(address: string): void { + const existing = this._labelFormatters.get(address); + if (existing) { + existing.dispose(); + this._labelFormatters.delete(address); + } + } + override dispose(): void { for (const timeout of this._reconnectTimeouts.values()) { clearTimeout(timeout); @@ -579,6 +636,10 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo entry.store.dispose(); } this._entries.clear(); + for (const handle of this._labelFormatters.values()) { + handle.dispose(); + } + this._labelFormatters.clear(); super.dispose(); } } diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts index d4b9d0f2b0f02..3df746cfd2e5f 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts @@ -11,8 +11,10 @@ import { ILogService, NullLogService } from '../../../log/common/log.js'; import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js'; import { IConfigurationService, type IConfigurationChangeEvent } from '../../../configuration/common/configuration.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; +import { ILabelService, type ResourceLabelFormatter } from '../../../label/common/label.js'; import { RemoteAgentHostService } from '../../browser/remoteAgentHostServiceImpl.js'; import { parseRemoteAgentHostInput, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, entryToRawEntry, type IRawRemoteAgentHostEntry, type IRemoteAgentHostEntry } from '../../common/remoteAgentHostService.js'; +import { AGENT_HOST_SCHEME, agentHostAuthority } from '../../common/agentHostUri.js'; import { DeferredPromise } from '../../../../base/common/async.js'; // ---- Mock protocol client --------------------------------------------------- @@ -98,6 +100,7 @@ suite('RemoteAgentHostService', () => { const disposables = new DisposableStore(); let configService: TestConfigurationService; let createdClients: MockProtocolClient[]; + let registeredFormatters: ResourceLabelFormatter[]; let service: RemoteAgentHostService; setup(() => { @@ -109,6 +112,18 @@ suite('RemoteAgentHostService', () => { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IConfigurationService, configService as Partial); + registeredFormatters = []; + instantiationService.stub(ILabelService, { + registerFormatter(formatter: ResourceLabelFormatter) { + registeredFormatters.push(formatter); + return toDisposable(() => { + const idx = registeredFormatters.indexOf(formatter); + if (idx >= 0) { + registeredFormatters.splice(idx, 1); + } + }); + }, + } as Partial); // Mock the instantiation service to capture created protocol clients const mockInstantiationService: Partial = { @@ -490,4 +505,47 @@ suite('RemoteAgentHostService', () => { assert.strictEqual(t.disposed(), true, 'transport disposable runs when service is disposed'); }); }); + + suite('host label formatter', () => { + + function formatterFor(address: string): ResourceLabelFormatter | undefined { + const authority = agentHostAuthority(address); + return registeredFormatters.find(f => f.scheme === AGENT_HOST_SCHEME && f.authority === authority); + } + + test('registers formatter when an entry is added', async () => { + configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); + + const formatter = formatterFor('host1:8080'); + assert.ok(formatter, 'formatter is registered'); + assert.strictEqual(formatter.formatting.workspaceSuffix, 'Host 1'); + }); + + test('refreshes formatter when an entry name changes', async () => { + configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); + configService.setEntries([{ name: 'Renamed', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); + + const matching = registeredFormatters.filter(f => f.authority === agentHostAuthority('host1:8080')); + assert.strictEqual(matching.length, 1, 'old formatter is replaced, not duplicated'); + assert.strictEqual(matching[0].formatting.workspaceSuffix, 'Renamed'); + }); + + test('removes formatter when an entry is removed', async () => { + configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); + assert.ok(formatterFor('host1:8080')); + + configService.setEntries([]); + + assert.strictEqual(formatterFor('host1:8080'), undefined); + }); + + test('removes formatters when the service is disabled', async () => { + configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); + assert.ok(formatterFor('host1:8080')); + + configService.setEnabled(false); + + assert.strictEqual(formatterFor('host1:8080'), undefined); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostPermissionUiContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostPermissionUiContribution.ts index 3ad688510fb77..b284beef5b878 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostPermissionUiContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostPermissionUiContribution.ts @@ -13,8 +13,9 @@ import { IAgentHostPermissionService, IPendingResourceRequest, } from '../../../../../../platform/agentHost/common/agentHostPermissionService.js'; -import { IRemoteAgentHostService } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { AGENT_HOST_SCHEME, agentHostAuthority } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { CommandsRegistry } from '../../../../../../platform/commands/common/commands.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution } from '../../../../../common/contributions.js'; import { @@ -61,7 +62,7 @@ export class AgentHostPermissionUiContribution extends Disposable implements IWo constructor( @IAgentHostPermissionService private readonly _permissionService: IAgentHostPermissionService, @IChatInputNotificationService private readonly _chatInputNotificationService: IChatInputNotificationService, - @IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService, + @ILabelService private readonly _labelService: ILabelService, ) { super(); @@ -147,6 +148,8 @@ export class AgentHostPermissionUiContribution extends Disposable implements IWo } private _resolveHostName(address: string): string { - return this._remoteAgentHostService.getEntryByAddress(address)?.name ?? address; + const authority = agentHostAuthority(address); + const label = this._labelService.getHostLabel(AGENT_HOST_SCHEME, authority); + return label && label !== authority ? label : address; } } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPermissionUiContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPermissionUiContribution.test.ts index a920f23f082ed..416b0ae76fd60 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPermissionUiContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPermissionUiContribution.test.ts @@ -14,14 +14,11 @@ import { IAgentHostPermissionService, IPendingResourceRequest, } from '../../../../../../platform/agentHost/common/agentHostPermissionService.js'; -import { - IRemoteAgentHostConnectionInfo, - IRemoteAgentHostEntry, - IRemoteAgentHostService, - RemoteAgentHostEntryType, -} from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { AGENT_HOST_SCHEME, agentHostAuthority } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { Event } from '../../../../../../base/common/event.js'; +import { MockLabelService } from '../../../../../services/label/test/common/mockLabelService.js'; import { AgentHostPermissionUiContribution } from '../../../browser/agentSessions/agentHost/agentHostPermissionUiContribution.js'; import { IChatInputNotification, @@ -58,25 +55,24 @@ class FakeNotificationService implements IChatInputNotificationService { handleMessageSent(): void { /* */ } } -class FakeRemoteAgentHostService implements IRemoteAgentHostService { - declare readonly _serviceBrand: undefined; - readonly onDidChangeConnections: Event = Event.None; - readonly connections: readonly IRemoteAgentHostConnectionInfo[] = []; - readonly configuredEntries: readonly IRemoteAgentHostEntry[] = []; - - private readonly _entries = new Map(); - - setEntry(address: string, name: string): void { - this._entries.set(address, { name, connection: { type: RemoteAgentHostEntryType.WebSocket, address } }); +/** + * Mock label service that resolves host labels for the {@link AGENT_HOST_SCHEME} + * by mapping authorities encoded via {@link agentHostAuthority} to the + * friendly name registered through {@link StubLabelService.setHostName}. + * Unknown authorities are returned unchanged. + */ +class StubLabelService extends MockLabelService { + private readonly _hostLabels = new Map(); + + setHostName(address: string, name: string): void { + this._hostLabels.set(agentHostAuthority(address), name); } - getConnection() { return undefined; } - async addRemoteAgentHost(): Promise { throw new Error('not used'); } - async removeRemoteAgentHost(): Promise { /* */ } - reconnect(): void { /* */ } - async addManagedConnection(): Promise { throw new Error('not used'); } - getEntryByAddress(address: string): IRemoteAgentHostEntry | undefined { - return this._entries.get(address); + override getHostLabel(scheme: string, authority?: string): string { + if (scheme === AGENT_HOST_SCHEME && authority && this._hostLabels.has(authority)) { + return this._hostLabels.get(authority)!; + } + return authority ?? ''; } } @@ -101,20 +97,20 @@ suite('AgentHostPermissionUiContribution', () => { let permissionService: FakePermissionService; let notificationService: FakeNotificationService; - let remoteAgentHostService: FakeRemoteAgentHostService; + let labelService: StubLabelService; setup(() => { permissionService = disposables.add(new FakePermissionService()); notificationService = new FakeNotificationService(); - remoteAgentHostService = new FakeRemoteAgentHostService(); - remoteAgentHostService.setEntry('host:1234', 'My Host'); + labelService = new StubLabelService(); + labelService.setHostName('host:1234', 'My Host'); }); function createContribution(): AgentHostPermissionUiContribution { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IAgentHostPermissionService, permissionService); instantiationService.stub(IChatInputNotificationService, notificationService); - instantiationService.stub(IRemoteAgentHostService, remoteAgentHostService); + instantiationService.stub(ILabelService, labelService); const contribution = instantiationService.createInstance(AgentHostPermissionUiContribution); disposables.add(contribution as unknown as IDisposable); return contribution; From d365bcbe3c3b1ed17d425f836c6158028569811a Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst <39639992+hawkticehurst@users.noreply.github.com> Date: Wed, 6 May 2026 18:33:29 -0400 Subject: [PATCH 23/34] sessions: retain attached editor maximize state (#314841) * sessions: retain attached editor maximize state Remember the maximized state of main editors that are reopened from the auxiliary-bar-attached flow so closing and reopening an embedded editor preserves its layout. Keep modal editor flows unchanged and document the behavior in the sessions layout spec. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: clear attached editor restore on aux hide Cancel the remembered attached-editor maximize restore when the auxiliary bar is hidden and add regression coverage for hiding and showing the bar before the next editor reopen. Co-authored-by: Copilot <223556219+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> --- src/vs/sessions/LAYOUT.md | 2 + src/vs/sessions/browser/workbench.ts | 20 +++ .../sessions/test/browser/workbench.test.ts | 122 ++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 src/vs/sessions/test/browser/workbench.test.ts diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index e2e93d5c01579..104ef9e282024 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -259,6 +259,7 @@ setPartHidden(hidden: boolean, part: Parts): void - The main editor part is hidden by default but can be shown for explicit editor workflows that target the main editor part - Modal editor opens do not change the current main editor visibility state - The sessions **Maximize Editor** action temporarily hides the panel when the visible panel is the terminal view, and the matching **Restore Editor** action reopens that terminal panel if maximize hid it + - When a maximized main editor attached to the auxiliary bar closes, the next attached-editor reopen restores that maximized state only if the auxiliary bar is visible at reopen time; hiding and later re-showing the auxiliary bar does not by itself clear this pending restore state. Modal editor flows do not participate in this restore behavior - All editors open via `MODAL_GROUP` into the `ModalEditorPart` overlay, which manages its own lifecycle ### 6.2 Part Sizing @@ -665,6 +666,7 @@ interface IPartVisibilityState { | Date | Change | |------|--------| +| 2026-05-01 | Updated the sessions main-editor lifecycle so maximized editors attached to the auxiliary bar remember their maximized state across close/reopen, while modal editor flows continue to ignore that remembered state. | | 2026-04-28 | Updated the sessions "Open in VS Code" titlebar widget to match the core "Open in Agents" affordance more closely: the product icon is greyscale by default, animates back to full color on hover/focus when motion is enabled, uses secondary-button hover chrome instead of quality-tinted backgrounds, and draws a separator before the Run split button. | | 2026-04-27 | Made the sessions shell gradient background the default treatment by removing the `sessions.experimental.shellGradientBackground` opt-in, always applying the root shell gradient layer, and renaming the workbench CSS hook to `shell-gradient-background`. | | 2026-04-23 | Updated mobile layout policy platform detection to use shared `platform.isMobile`, and reduced phone-layout CSS `!important` usage where selector specificity already provides stable overrides. | diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index bd1a0254fe8cf..66bc8a524b88b 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -282,6 +282,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private _editorMaximized = false; private _editorLastNonMaximizedVisibility: IPartVisibilityState | undefined; + private _restoreAttachedEditorMaximizedOnShow = false; private readonly restoredPromise = new DeferredPromise(); readonly whenRestored = this.restoredPromise.p; @@ -903,12 +904,14 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic if (!this.partVisibility.editor) { this.setEditorHidden(false); + this.restoreAttachedEditorMaximizedState(); } })); // Hide editor part when last editor closes this._register(this.editorService.onDidCloseEditor(() => { if (this.partVisibility.editor && this.areAllGroupsEmpty()) { + this.rememberAttachedEditorMaximizedState(); this.setEditorHidden(true); } })); @@ -935,6 +938,19 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic return true; } + private rememberAttachedEditorMaximizedState(): void { + this._restoreAttachedEditorMaximizedOnShow = this._editorMaximized && this.partVisibility.auxiliaryBar; + } + + private restoreAttachedEditorMaximizedState(): void { + const shouldRestore = this._restoreAttachedEditorMaximizedOnShow && this.partVisibility.auxiliaryBar; + this._restoreAttachedEditorMaximizedOnShow = false; + + if (shouldRestore) { + this.setEditorMaximized(true); + } + } + private registerLayoutListeners(): void { // Fullscreen changes this._register(onDidChangeFullscreen(windowId => { @@ -1486,6 +1502,10 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic return; } + if (hidden) { + this._restoreAttachedEditorMaximizedOnShow = false; + } + this.partVisibility.auxiliaryBar = !hidden; this.mainContainer.classList.toggle(LayoutClasses.AUXILIARYBAR_HIDDEN, hidden); diff --git a/src/vs/sessions/test/browser/workbench.test.ts b/src/vs/sessions/test/browser/workbench.test.ts new file mode 100644 index 0000000000000..c11ecd6b8ce00 --- /dev/null +++ b/src/vs/sessions/test/browser/workbench.test.ts @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { Workbench } from '../../browser/workbench.js'; + +interface IWorkbenchTestHarness { + partVisibility: { + sidebar: boolean; + auxiliaryBar: boolean; + editor: boolean; + panel: boolean; + chatBar: boolean; + }; + _editorMaximized: boolean; + _restoreAttachedEditorMaximizedOnShow: boolean; + setEditorMaximized(maximized: boolean): void; + setAuxiliaryBarHidden(hidden: boolean): void; +} + +suite('Sessions - Workbench', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const rememberAttachedEditorMaximizedState = Reflect.get(Workbench.prototype, 'rememberAttachedEditorMaximizedState') as (this: IWorkbenchTestHarness) => void; + const restoreAttachedEditorMaximizedState = Reflect.get(Workbench.prototype, 'restoreAttachedEditorMaximizedState') as (this: IWorkbenchTestHarness) => void; + const setAuxiliaryBarHidden = Reflect.get(Workbench.prototype, 'setAuxiliaryBarHidden') as (this: IWorkbenchTestHarness, hidden: boolean) => void; + + function createWorkbenchHarness(): IWorkbenchTestHarness { + return { + partVisibility: { + sidebar: true, + auxiliaryBar: true, + editor: true, + panel: false, + chatBar: true, + }, + _editorMaximized: false, + _restoreAttachedEditorMaximizedOnShow: false, + setEditorMaximized: () => { }, + setAuxiliaryBarHidden: () => { }, + }; + } + + test('restores attached editor maximized state when the auxiliary bar stays visible', () => { + const maximizedStates: boolean[] = []; + const workbench = createWorkbenchHarness(); + workbench._editorMaximized = true; + workbench.setEditorMaximized = maximized => maximizedStates.push(maximized); + + rememberAttachedEditorMaximizedState.call(workbench); + + workbench._editorMaximized = false; + restoreAttachedEditorMaximizedState.call(workbench); + + assert.deepStrictEqual(maximizedStates, [true]); + assert.strictEqual(workbench._restoreAttachedEditorMaximizedOnShow, false); + }); + + test('does not restore attached editor maximized state once the auxiliary bar is hidden', () => { + const maximizedStates: boolean[] = []; + const workbench = createWorkbenchHarness(); + workbench._editorMaximized = true; + workbench.setEditorMaximized = maximized => maximizedStates.push(maximized); + + rememberAttachedEditorMaximizedState.call(workbench); + + workbench._editorMaximized = false; + workbench.partVisibility.auxiliaryBar = false; + restoreAttachedEditorMaximizedState.call(workbench); + + assert.deepStrictEqual(maximizedStates, []); + assert.strictEqual(workbench._restoreAttachedEditorMaximizedOnShow, false); + }); + + test('does not restore after the auxiliary bar is hidden and shown again before reopen', () => { + const maximizedStates: boolean[] = []; + const workbench = createWorkbenchHarness(); + workbench._editorMaximized = true; + workbench.setEditorMaximized = maximized => maximizedStates.push(maximized); + workbench.setAuxiliaryBarHidden = hidden => { + workbench.partVisibility.auxiliaryBar = !hidden; + }; + (workbench as IWorkbenchTestHarness & { + mainContainer: { classList: { toggle(): void } }; + workbenchGrid: { setViewVisible(): void }; + auxiliaryBarPartView: {}; + paneCompositeService: { getActivePaneComposite(): undefined; hideActivePaneComposite(): void; openPaneComposite(): void; getLastActivePaneCompositeId(): undefined }; + viewDescriptorService: { getDefaultViewContainer(): undefined }; + }).mainContainer = { classList: { toggle: () => { } } }; + (workbench as IWorkbenchTestHarness & { + workbenchGrid: { setViewVisible(): void }; + auxiliaryBarPartView: {}; + }).workbenchGrid = { setViewVisible: () => { } }; + (workbench as IWorkbenchTestHarness & { auxiliaryBarPartView: {} }).auxiliaryBarPartView = {}; + (workbench as IWorkbenchTestHarness & { + paneCompositeService: { getActivePaneComposite(): undefined; hideActivePaneComposite(): void; openPaneComposite(): void; getLastActivePaneCompositeId(): undefined }; + }).paneCompositeService = { + getActivePaneComposite: () => undefined, + hideActivePaneComposite: () => { }, + openPaneComposite: () => { }, + getLastActivePaneCompositeId: () => undefined, + }; + (workbench as IWorkbenchTestHarness & { + viewDescriptorService: { getDefaultViewContainer(): undefined }; + }).viewDescriptorService = { + getDefaultViewContainer: () => undefined, + }; + + rememberAttachedEditorMaximizedState.call(workbench); + setAuxiliaryBarHidden.call(workbench, true); + setAuxiliaryBarHidden.call(workbench, false); + + workbench._editorMaximized = false; + restoreAttachedEditorMaximizedState.call(workbench); + + assert.deepStrictEqual(maximizedStates, []); + assert.strictEqual(workbench._restoreAttachedEditorMaximizedOnShow, false); + }); +}); From c2a42605f7858dd775613df5bb303067ebdca273 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Wed, 6 May 2026 15:42:18 -0700 Subject: [PATCH 24/34] Terminate COM surrogate process before update/gc (#313791) --------- Co-authored-by: Copilot --- build/gulpfile.vscode.win32.ts | 4 ++ build/win32/code.iss | 29 ++++++++++++++ src/vs/base/common/product.ts | 1 + src/vs/code/electron-main/main.ts | 32 ++++++++++++--- .../electron-main/updateService.win32.ts | 39 +++++++++++++++++++ 5 files changed, 99 insertions(+), 6 deletions(-) diff --git a/build/gulpfile.vscode.win32.ts b/build/gulpfile.vscode.win32.ts index 7a711276be59d..01070c9503c05 100644 --- a/build/gulpfile.vscode.win32.ts +++ b/build/gulpfile.vscode.win32.ts @@ -135,6 +135,10 @@ function buildWin32Setup(arch: string, target: string): task.CallbackTask { definitions['AppxPackage'] = `${quality === 'stable' ? 'code' : 'code_insider'}_${arch}.appx`; definitions['AppxPackageDll'] = `${quality === 'stable' ? 'code' : 'code_insider'}_explorer_command_${arch}.dll`; definitions['AppxPackageName'] = `${product.win32AppUserModelId}`; + const ctxMenu = (product as { win32ContextMenu?: Record }).win32ContextMenu; + if (ctxMenu && ctxMenu[arch]) { + definitions['FileExplorerContextMenuCLSID'] = ctxMenu[arch].clsid; + } } fs.writeFileSync(productJsonPath, JSON.stringify(productJson, undefined, '\t')); diff --git a/build/win32/code.iss b/build/win32/code.iss index 7f23530dcb1a7..594453ed7b989 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -1718,6 +1718,32 @@ begin Result := False; end; +// Unblock inno_updater --gc when our context-menu COM surrogate keeps a +// handle on the orphan commit folder. See https://github.com/microsoft/vscode/issues/294546. +// No-op when FileExplorerContextMenuCLSID is not defined (e.g. OSS builds). +procedure KillContextMenuComSurrogate(); +var + KillErrorCode: Integer; + Command: String; +begin +#ifdef FileExplorerContextMenuCLSID + Log('KillContextMenuComSurrogate: stopping COM surrogate(s) hosting context-menu DLL ({#FileExplorerContextMenuCLSID})'); + + Command := + '-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command "' + + 'Get-CimInstance Win32_Process -Filter ""Name = ''dllhost.exe''"" | ' + + 'Where-Object { $_.CommandLine -like ''*/Processid:{#FileExplorerContextMenuCLSID}*'' } | ' + + 'ForEach-Object { try { Stop-Process -Id $_.ProcessId -Force -ErrorAction Stop } catch {} }"'; + + if not ShellExec('', 'powershell.exe', Command, '', SW_HIDE, ewWaitUntilTerminated, KillErrorCode) then + Log('KillContextMenuComSurrogate: ShellExec failed with error code ' + IntToStr(KillErrorCode)) + else if KillErrorCode <> 0 then + Log('KillContextMenuComSurrogate: PowerShell exited with non-zero code ' + IntToStr(KillErrorCode)) + else + Log('KillContextMenuComSurrogate: complete'); +#endif +end; + #ifdef AppxPackageName var AppxPackageFullname: String; @@ -1776,6 +1802,7 @@ procedure RemoveAppxPackage(); var RemoveAppxPackageResultCode: Integer; begin + KillContextMenuComSurrogate(); // Remove the old context menu package // Following condition can be removed in v1.111. if QualityIsInsiders() and not SessionEndFileExists() and AppxPackageInstalled('Microsoft.VSCodeInsiders', RemoveAppxPackageResultCode) then begin @@ -1860,6 +1887,7 @@ begin Log('inno_updater completed successfully'); #if "system" == InstallTarget if IsVersionedUpdate() then begin + KillContextMenuComSurrogate(); Log('Invoking inno_updater to remove previous installation folder'); Exec(ExpandConstant('{app}\{#VersionedResourcesFolder}\tools\inno_updater.exe'), ExpandConstant('"--gc" "{app}\{#ExeBasename}.exe" "{#VersionedResourcesFolder}" "{#ExeBasename}.exe"' {#ifdef ProxyExeBasename} + ' "{#ProxyExeBasename}.exe"' {#endif}), '', SW_SHOW, ewWaitUntilTerminated, UpdateResultCode); Log('inno_updater completed gc successfully'); @@ -1870,6 +1898,7 @@ begin end; end else begin if IsVersionedUpdate() then begin + KillContextMenuComSurrogate(); Log('Invoking inno_updater to remove previous installation folder'); Exec(ExpandConstant('{app}\{#VersionedResourcesFolder}\tools\inno_updater.exe'), ExpandConstant('"--gc" "{app}\{#ExeBasename}.exe" "{#VersionedResourcesFolder}" "{#ExeBasename}.exe"' {#ifdef ProxyExeBasename} + ' "{#ProxyExeBasename}.exe"' {#endif}), '', SW_SHOW, ewWaitUntilTerminated, UpdateResultCode); Log('inno_updater completed gc successfully'); diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index bc7c8f68eeb92..f594d9451aefa 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -80,6 +80,7 @@ export interface IProductConfiguration { readonly win32NameVersion?: string; readonly win32VersionedUpdate?: boolean; readonly win32SiblingExeBasename?: string; + readonly win32ContextMenu?: { readonly [arch: string]: { readonly clsid: string } }; readonly applicationName: string; readonly embedderIdentifier?: string; readonly telemetryAppName?: string; diff --git a/src/vs/code/electron-main/main.ts b/src/vs/code/electron-main/main.ts index 6ad77c91cac3b..dbc801366dd90 100644 --- a/src/vs/code/electron-main/main.ts +++ b/src/vs/code/electron-main/main.ts @@ -9,7 +9,7 @@ import { app, dialog } from 'electron'; import { unlinkSync, promises } from 'fs'; import { URI } from '../../base/common/uri.js'; import { coalesce, distinct } from '../../base/common/arrays.js'; -import { Promises } from '../../base/common/async.js'; +import { Promises, retry } from '../../base/common/async.js'; import { toErrorMessage } from '../../base/common/errorMessage.js'; import { ExpectedError, setUnexpectedErrorHandler } from '../../base/common/errors.js'; import { IPathWithLineAndColumn, isValidBasename, parseLineAndColumnAware, sanitizeFilePath } from '../../base/common/extpath.js'; @@ -144,8 +144,8 @@ class CodeMain { evt.join('instanceLockfile', promises.unlink(environmentMainService.mainLockfile).catch(() => { /* ignored */ })); }); - // Check if Inno Setup is running - const innoSetupActive = await this.checkInnoSetupMutex(productService); + // Check if Inno Setup is running. Briefly wait for the updating mutex to be released before refusing to launch. + const innoSetupActive = await this.checkInnoSetupMutex(productService, logService); if (innoSetupActive) { const message = `${productService.nameShort} is currently being updated. Please wait for the update to complete before launching.`; instantiationService.invokeFunction(this.quit, new Error(message)); @@ -501,7 +501,7 @@ class CodeMain { lifecycleMainService.kill(exitCode); } - private async checkInnoSetupMutex(productService: IProductService): Promise { + private async checkInnoSetupMutex(productService: IProductService, logService: ILogService): Promise { if (!(isWindows && productService.win32MutexName && productService.win32VersionedUpdate)) { return false; } @@ -509,9 +509,29 @@ class CodeMain { try { const updatingMutexName = `${productService.win32MutexName}-updating`; const mutex = await import('@vscode/windows-mutex'); - return mutex.isActive(updatingMutexName); + + if (!mutex.isActive(updatingMutexName)) { + return false; + } + + // Wait briefly for setup teardown to release the mutex; Inno's `nowait postinstall` runcode can race the setup process exit. + const pollIntervalMs = 250, retries = 120; // 30s total + logService.info(`checkInnoSetupMutex: ${updatingMutexName} is held, waiting up to ${(pollIntervalMs * retries) / 1000}s for setup to finish...`); + const start = Date.now(); + try { + await retry(async () => { + if (mutex.isActive(updatingMutexName)) { + throw new Error('mutex still held'); + } + }, pollIntervalMs, retries); + logService.info(`checkInnoSetupMutex: ${updatingMutexName} released after ${Date.now() - start}ms`); + return false; + } catch { + logService.warn(`checkInnoSetupMutex: ${updatingMutexName} still held after ${Date.now() - start}ms, giving up`); + return true; + } } catch (error) { - console.error('Failed to check Inno Setup mutex:', error); + logService.error('Failed to check Inno Setup mutex:', error); return false; } } diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index 63327397766f1..6b97e209fb7be 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -164,6 +164,9 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const innoUpdater = path.join(exeDir, versionedResourcesFolder, 'tools', 'inno_updater.exe'); const exeName = basename(exePath); const siblingExeName = this.productService.win32SiblingExeBasename ? `${this.productService.win32SiblingExeBasename}.exe` : ''; + // Unblock inno_updater --gc when our context-menu COM surrogate keeps a + // handle on the orphan commit folder. See https://github.com/microsoft/vscode/issues/294546. + await this.killContextMenuComSurrogate(); await new Promise(resolve => { const child = spawn(innoUpdater, ['--gc', exePath, versionedResourcesFolder, exeName, siblingExeName], { stdio: ['ignore', 'ignore', 'ignore'], @@ -176,6 +179,42 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun } } + private async killContextMenuComSurrogate(): Promise { + const clsid = this.productService.win32ContextMenu?.[process.arch]?.clsid; + if (!clsid) { + return; + } + + const command = + `Get-CimInstance Win32_Process -Filter "Name = 'dllhost.exe'" | ` + + `Where-Object { $_.CommandLine -like '*/Processid:${clsid}*' } | ` + + `ForEach-Object { try { Stop-Process -Id $_.ProcessId -Force -ErrorAction Stop } catch {} }`; + + await new Promise(resolve => { + try { + spawn('powershell.exe', [ + '-NoLogo', '-NoProfile', '-NonInteractive', + '-WindowStyle', 'Hidden', + '-ExecutionPolicy', 'Bypass', + '-Command', command + ], { + stdio: ['ignore', 'ignore', 'ignore'], + windowsHide: true, + timeout: 5 * 1000 + }).once('exit', code => { + this.logService.info(`update#killContextMenuComSurrogate: powershell exited with code ${code}`); + resolve(); + }).once('error', err => { + this.logService.warn(`update#killContextMenuComSurrogate: failed to spawn powershell: ${err}`); + resolve(); + }); + } catch (err) { + this.logService.warn(`update#killContextMenuComSurrogate: spawn threw: ${err}`); + resolve(); + } + }); + } + protected buildUpdateFeedUrl(quality: string, commit: string, options?: IUpdateURLOptions): string | undefined { let platform = `win32-${process.arch}`; From 21b561c67fe2b2ec28eed9283a4626b5f7c0d030 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 6 May 2026 15:47:10 -0700 Subject: [PATCH 25/34] agent-host: restore selected model in picker (#314613) * agent-host: restore selected model in picker Use session state as the source of truth for existing agent host sessions, while keeping resource-scheme-scoped storage as the view-state fallback for new untitled sessions. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agent-host: track status for model picker restore Recompute Agent Host model picker state when the active session status changes so storage fallback is only used while a session is untitled. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentHost/agentHostModelPicker.ts | 70 +++++++++------- .../test/browser/agentHostModelPicker.test.ts | 82 +++++++++++++++++++ 2 files changed, 121 insertions(+), 31 deletions(-) create mode 100644 src/vs/sessions/contrib/chat/test/browser/agentHostModelPicker.test.ts diff --git a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostModelPicker.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostModelPicker.ts index ab0bd240daf08..78578a68d4ad7 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostModelPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostModelPicker.ts @@ -17,7 +17,7 @@ import { type ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } import { type IChatInputPickerOptions } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; import { ModelPickerActionItem, type IModelPickerDelegate } from '../../../../../workbench/contrib/chat/browser/widget/input/modelPickerActionItem.js'; import { ActiveSessionProviderIdContext, IsPhoneLayoutContext } from '../../../../common/contextkeys.js'; -import { type ISession } from '../../../../services/sessions/common/session.js'; +import { SessionStatus, type ISession } from '../../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { Menus } from '../../../../browser/menus.js'; @@ -52,7 +52,10 @@ registerAction2(class extends Action2 { // -- Agent Host Model Picker Contribution -- -function getAgentHostModels( +/** + * Gets the language models registered for the active agent-host session resource scheme. + */ +export function getAgentHostModels( languageModelsService: ILanguageModelsService, session: ISession | undefined, ): ILanguageModelChatMetadataAndIdentifier[] { @@ -72,7 +75,25 @@ function getAgentHostModels( .filter((m): m is ILanguageModelChatMetadataAndIdentifier => !!m && m.metadata.targetChatSessionType === resourceScheme); } -const STORAGE_KEY = 'sessions.agentHostModelPicker.selectedModelId'; +export function agentHostModelPickerStorageKey(resourceScheme: string): string { + return `workbench.agentsession.agentHostModelPicker.${resourceScheme}.selectedModelId`; +} + +/** + * Resolves the model that should be shown for a session. + */ +export function resolveAgentHostModel( + models: readonly ILanguageModelChatMetadataAndIdentifier[], + sessionModelId: string | undefined, + storedModelId: string | undefined, +): ILanguageModelChatMetadataAndIdentifier | undefined { + const sessionModel = sessionModelId ? models.find(model => model.identifier === sessionModelId) : undefined; + if (sessionModel) { + return sessionModel; + } + + return storedModelId ? models.find(model => model.identifier === storedModelId) : undefined; +} class AgentHostModelPickerContribution extends Disposable implements IWorkbenchContribution { @@ -96,9 +117,9 @@ class AgentHostModelPickerContribution extends Disposable implements IWorkbenchC currentModel, setModel: (model: ILanguageModelChatMetadataAndIdentifier) => { currentModel.set(model, undefined); - storageService.store(STORAGE_KEY, model.identifier, StorageScope.PROFILE, StorageTarget.MACHINE); const session = sessionsManagementService.activeSession.get(); if (session) { + storageService.store(agentHostModelPickerStorageKey(session.resource.scheme), model.identifier, StorageScope.PROFILE, StorageTarget.MACHINE); const provider = sessionsProvidersService.getProviders().find(p => p.id === session.providerId); provider?.setModel(session.sessionId, model.identifier); } @@ -115,31 +136,27 @@ class AgentHostModelPickerContribution extends Disposable implements IWorkbenchC const action = { id: 'sessions.agentHost.modelPicker', label: '', enabled: true, class: undefined, tooltip: '', run: () => { } }; const modelPicker = instantiationService.createInstance(ModelPickerActionItem, action, delegate, pickerOptions); - const rememberedModelId = storageService.get(STORAGE_KEY, StorageScope.PROFILE); - const initModel = (session: ISession | undefined, sessionModelId: string | undefined) => { + const initModel = (session: ISession | undefined, sessionModelId: string | undefined, isUntitled: boolean) => { const models = getAgentHostModels(languageModelsService, session); modelPicker.setEnabled(models.length > 0); - let resolvedModel = sessionModelId - ? models.find(model => model.identifier === sessionModelId) - : undefined; - - // When no model is explicitly selected, restore the - // remembered model or pick the first available one so - // the picker shows a real model name instead of the - // misleading "Auto" label (the copilot "auto" - // pseudo-model is not available in agent host sessions). - if (!resolvedModel && models.length > 0) { - const remembered = rememberedModelId ? models.find(m => m.identifier === rememberedModelId) : undefined; - resolvedModel = remembered ?? models[0]; - delegate.setModel(resolvedModel); + if (!session || models.length === 0) { + currentModel.set(undefined, undefined); + return; } + const storedModelId = isUntitled + ? storageService.get(agentHostModelPickerStorageKey(session.resource.scheme), StorageScope.PROFILE) + : undefined; + const resolvedModel = resolveAgentHostModel(models, sessionModelId, storedModelId); currentModel.set(resolvedModel, undefined); + if (!sessionModelId && isUntitled && resolvedModel) { + delegate.setModel(resolvedModel); + } }; const initModelFromActiveSession = () => { const session = sessionsManagementService.activeSession.get(); - initModel(session, session?.modelId.get()); + initModel(session, session?.modelId.get(), session?.status.get() === SessionStatus.Untitled); }; initModelFromActiveSession(); @@ -149,17 +166,8 @@ class AgentHostModelPickerContribution extends Disposable implements IWorkbenchC disposableStore.add(autorun(reader => { const session = sessionsManagementService.activeSession.read(reader); const sessionModelId = session?.modelId.read(reader); - initModel(session, sessionModelId); - })); - - // When the active session changes, push the selected model to the new session - disposableStore.add(autorun(reader => { - const session = sessionsManagementService.activeSession.read(reader); - const model = currentModel.read(reader); - if (session && model) { - const provider = sessionsProvidersService.getProviders().find(p => p.id === session.providerId); - provider?.setModel(session.sessionId, model.identifier); - } + const isUntitled = session?.status.read(reader) === SessionStatus.Untitled; + initModel(session, sessionModelId, isUntitled); })); return new AgentHostPickerActionViewItem(modelPicker, disposableStore); diff --git a/src/vs/sessions/contrib/chat/test/browser/agentHostModelPicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/agentHostModelPicker.test.ts new file mode 100644 index 0000000000000..89279f1d9f060 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/agentHostModelPicker.test.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; +import type { ILanguageModelChatMetadataAndIdentifier } from '../../../../../workbench/contrib/chat/common/languageModels.js'; +import { agentHostModelPickerStorageKey, resolveAgentHostModel } from '../../browser/agentHost/agentHostModelPicker.js'; + +function makeModel(identifier: string): ILanguageModelChatMetadataAndIdentifier { + return { + identifier, + metadata: { + extension: new ExtensionIdentifier('test.ext'), + id: identifier, + name: identifier, + vendor: 'copilot', + version: '1.0', + family: 'copilot', + maxInputTokens: 128000, + maxOutputTokens: 4096, + isDefaultForLocation: {}, + isUserSelectable: true, + modelPickerCategory: undefined, + targetChatSessionType: 'agent-host-copilotcli', + }, + }; +} + +suite('AgentHostModelPicker', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('uses resource-scheme-scoped storage keys', () => { + assert.strictEqual( + agentHostModelPickerStorageKey('agent-host-copilotcli'), + 'workbench.agentsession.agentHostModelPicker.agent-host-copilotcli.selectedModelId', + ); + assert.strictEqual( + agentHostModelPickerStorageKey('remote-localhost__4321-copilotcli'), + 'workbench.agentsession.agentHostModelPicker.remote-localhost__4321-copilotcli.selectedModelId', + ); + }); + + test('uses the current session model from session state', () => { + const models = [ + makeModel('agent-host-copilotcli:other'), + makeModel('agent-host-copilotcli:session'), + ]; + + assert.strictEqual( + resolveAgentHostModel(models, 'agent-host-copilotcli:session', 'agent-host-copilotcli:other'), + models[1], + ); + }); + + test('does not synthesize a model for existing sessions without one in state', () => { + const models = [ + makeModel('agent-host-copilotcli:first'), + ]; + + assert.strictEqual(resolveAgentHostModel(models, undefined, undefined), undefined); + }); + + test('uses the stored model for new untitled sessions with no model yet', () => { + const models = [ + makeModel('agent-host-copilotcli:first'), + makeModel('agent-host-copilotcli:stored'), + ]; + + assert.strictEqual(resolveAgentHostModel(models, undefined, 'agent-host-copilotcli:stored'), models[1]); + }); + + test('does not fall back to the first model for new untitled sessions without stored state', () => { + const models = [ + makeModel('agent-host-copilotcli:first'), + ]; + + assert.strictEqual(resolveAgentHostModel(models, undefined, undefined), undefined); + }); +}); From 2f36dfd543178c66ef817342e2ad5f1fa1c4f430 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 6 May 2026 15:56:00 -0700 Subject: [PATCH 26/34] mcp: use chatStreamToolCallId for tool invocation permalinks (#314870) - Fixes tool invocation permalinks in MCP by using chatStreamToolCallId when available, with a fallback to callId for backward compatibility - Ensures tool results in chat responses are correctly linked to their stream tool call context Fixes #314706 (Commit message generated by Copilot) --- .../contrib/mcp/common/mcpLanguageModelToolContribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts index 4bb200467794a..4cea06b17f901 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts @@ -377,7 +377,7 @@ class McpToolImplementation implements IToolImpl { }); if (isForModel) { - const permalink = invocation.context && ChatResponseResource.createUri(invocation.context.sessionResource, invocation.callId, result.content.length, basename(uri)); + const permalink = invocation.context && ChatResponseResource.createUri(invocation.context.sessionResource, invocation.chatStreamToolCallId || invocation.callId, result.content.length, basename(uri)); addAsLinkedResource(permalink || uri, item.resource.mimeType); } } From 0bc083202961575f3169c131fbafa6712f88d010 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Wed, 6 May 2026 16:02:59 -0700 Subject: [PATCH 27/34] Fix setup localization files encoding (#312073) * Fix loc files encoding * Address review feedback: strip duplicate BOM and fix Hungarian disk-space key * Remove unused codePage / InnoSetup config from i18n pipeline * Strip leading BOM in TextModel so .isl parsing is BOM-tolerant --- build/gulpfile.vscode.ts | 17 +- build/lib/i18n.ts | 22 +- build/win32/i18n/Default.hu.isl | 470 ++++++++++++------------- build/win32/i18n/Default.ko.isl | 510 ++++++++++++++-------------- build/win32/i18n/Default.zh-cn.isl | 2 +- build/win32/i18n/Default.zh-tw.isl | 2 +- build/win32/i18n/messages.de.isl | 14 +- build/win32/i18n/messages.en.isl | 2 +- build/win32/i18n/messages.es.isl | 10 +- build/win32/i18n/messages.fr.isl | 14 +- build/win32/i18n/messages.hu.isl | 20 +- build/win32/i18n/messages.it.isl | 2 +- build/win32/i18n/messages.ja.isl | 20 +- build/win32/i18n/messages.ko.isl | 20 +- build/win32/i18n/messages.pt-br.isl | 10 +- build/win32/i18n/messages.ru.isl | 20 +- build/win32/i18n/messages.tr.isl | 20 +- build/win32/i18n/messages.zh-cn.isl | 20 +- build/win32/i18n/messages.zh-tw.isl | 20 +- 19 files changed, 601 insertions(+), 614 deletions(-) diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index 576ce07f85d7c..6205bfe48ed2e 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -714,21 +714,6 @@ BUILD_TARGETS.forEach(buildTarget => { // #region nls -const innoSetupConfig: Record = { - 'zh-cn': { codePage: 'CP936', defaultInfo: { name: 'Simplified Chinese', id: '$0804', } }, - 'zh-tw': { codePage: 'CP950', defaultInfo: { name: 'Traditional Chinese', id: '$0404' } }, - 'ko': { codePage: 'CP949', defaultInfo: { name: 'Korean', id: '$0412' } }, - 'ja': { codePage: 'CP932' }, - 'de': { codePage: 'CP1252' }, - 'fr': { codePage: 'CP1252' }, - 'es': { codePage: 'CP1252' }, - 'ru': { codePage: 'CP1251' }, - 'it': { codePage: 'CP1252' }, - 'pt-br': { codePage: 'CP1252' }, - 'hu': { codePage: 'CP1250' }, - 'tr': { codePage: 'CP1254' } -}; - gulp.task(task.define( 'vscode-translations-export', task.series( @@ -758,7 +743,7 @@ gulp.task('vscode-translations-import', function () { return es.merge([...i18n.defaultLanguages, ...i18n.extraLanguages].map(language => { const id = language.id; return gulp.src(`${options.location}/${id}/vscode-setup/messages.xlf`) - .pipe(i18n.prepareIslFiles(language, innoSetupConfig[language.id])) + .pipe(i18n.prepareIslFiles(language)) .pipe(vfs.dest(`./build/win32/i18n`)); })); }); diff --git a/build/lib/i18n.ts b/build/lib/i18n.ts index 61ed524f35bf8..5653e18aec5f0 100644 --- a/build/lib/i18n.ts +++ b/build/lib/i18n.ts @@ -12,7 +12,6 @@ import xml2js from 'xml2js'; import gulp from 'gulp'; import fancyLog from 'fancy-log'; import ansiColors from 'ansi-colors'; -import iconv from '@vscode/iconv-lite-umd'; import { type l10nJsonFormat, getL10nXlf, type l10nJsonDetails, getL10nFilesFromXlf, getL10nJson } from '@vscode/l10n-dev'; const REPO_ROOT_PATH = path.join(import.meta.dirname, '../..'); @@ -27,10 +26,6 @@ export interface Language { folderName?: string; // language specific folder name, e.g. cht, deu (optional, if not set, the id is used) } -export interface InnoSetup { - codePage: string; //code page for encoding (http://www.jrsoftware.org/ishelp/index.php?topic=langoptionssection) -} - export const defaultLanguages: Language[] = [ { id: 'zh-tw', folderName: 'cht', translationId: 'zh-hant' }, { id: 'zh-cn', folderName: 'chs', translationId: 'zh-hans' }, @@ -128,6 +123,11 @@ class TextModel { private _lines: string[]; constructor(contents: string) { + // Strip a leading UTF-8 BOM (U+FEFF) so callers can match on the + // first character of the first line without having to special-case it. + if (contents.charCodeAt(0) === 0xFEFF) { + contents = contents.slice(1); + } this._lines = contents.split(/\r\n|\r|\n/); } @@ -798,7 +798,7 @@ export function prepareI18nPackFiles(resultingTranslationPaths: TranslationPath[ }); } -export function prepareIslFiles(language: Language, innoSetupConfig: InnoSetup): eventStream.ThroughStream { +export function prepareIslFiles(language: Language): eventStream.ThroughStream { const parsePromises: Promise[] = []; return eventStream.through(function (this: eventStream.ThroughStream, xlf: File) { @@ -808,7 +808,7 @@ export function prepareIslFiles(language: Language, innoSetupConfig: InnoSetup): parsePromise.then( resolvedFiles => { resolvedFiles.forEach(file => { - const translatedFile = createIslFile(file.name, file.messages, language, innoSetupConfig); + const translatedFile = createIslFile(file.name, file.messages, language); stream.queue(translatedFile); }); } @@ -824,7 +824,7 @@ export function prepareIslFiles(language: Language, innoSetupConfig: InnoSetup): }); } -function createIslFile(name: string, messages: l10nJsonFormat, language: Language, innoSetup: InnoSetup): File { +function createIslFile(name: string, messages: l10nJsonFormat, language: Language): File { const content: string[] = []; let originalContent: TextModel; if (path.basename(name) === 'Default') { @@ -855,11 +855,13 @@ function createIslFile(name: string, messages: l10nJsonFormat, language: Languag const basename = path.basename(name); const filePath = `${basename}.${language.id}.isl`; - const encoded = iconv.encode(Buffer.from(content.join('\r\n'), 'utf8').toString(), innoSetup.codePage); + const utf8BOM = Buffer.from([0xEF, 0xBB, 0xBF]); + const contentBuffer = Buffer.from(content.join('\r\n'), 'utf8'); + const encoded = Buffer.concat([utf8BOM, contentBuffer]); return new File({ path: filePath, - contents: Buffer.from(encoded), + contents: encoded, }); } diff --git a/build/win32/i18n/Default.hu.isl b/build/win32/i18n/Default.hu.isl index 8c57d20a59aef..8045b4d0140ff 100644 --- a/build/win32/i18n/Default.hu.isl +++ b/build/win32/i18n/Default.hu.isl @@ -1,6 +1,6 @@ -;Inno Setup version 6.0.3+ Hungarian messages -;Based on the translation of Kornl Pl, kornelpal@gmail.com -;Istvn Szab, E-mail: istvanszabo890629@gmail.com +;Inno Setup version 6.0.3+ Hungarian messages +;Based on the translation of Kornél Pál, kornelpal@gmail.com +;István Szabó, E-mail: istvanszabo890629@gmail.com ; ; To download user-contributed translations of this file, go to: ; http://www.jrsoftware.org/files/istrans/ @@ -11,11 +11,11 @@ ; two periods being displayed). [LangOptions] -; The following three entries are very important. Be sure to read and +; The following three entries are very important. Be sure to read and ; understand the '[LangOptions] section' topic in the help file. LanguageName=Magyar LanguageID=$040E -LanguageCodePage=1250 +LanguageCodePage=0 ; If the language you are translating to requires special font faces or ; sizes, uncomment any of the following entries and change them accordingly. ;DialogFontName= @@ -30,282 +30,282 @@ LanguageCodePage=1250 [Messages] ; *** Application titles -SetupAppTitle=Telept -SetupWindowTitle=%1 - Telept -UninstallAppTitle=Eltvolt -UninstallAppFullTitle=%1 Eltvolt +SetupAppTitle=Telepítő +SetupWindowTitle=%1 - Telepítő +UninstallAppTitle=Eltávolító +UninstallAppFullTitle=%1 Eltávolító ; *** Misc. common -InformationTitle=Informcik -ConfirmTitle=Megerst +InformationTitle=Információk +ConfirmTitle=Megerősít ErrorTitle=Hiba ; *** SetupLdr messages -SetupLdrStartupMessage=%1 teleptve lesz. Szeretn folytatni? -LdrCannotCreateTemp=tmeneti fjl ltrehozsa nem lehetsges. A telepts megszaktva -LdrCannotExecTemp=Fjl futtatsa nem lehetsges az tmeneti knyvtrban. A telepts megszaktva +SetupLdrStartupMessage=%1 telepítve lesz. Szeretné folytatni? +LdrCannotCreateTemp=Átmeneti fájl létrehozása nem lehetséges. A telepítés megszakítva +LdrCannotExecTemp=Fájl futtatása nem lehetséges az átmeneti könyvtárban. A telepítés megszakítva HelpTextNote= ; *** Startup error messages LastErrorMessage=%1.%n%nHiba %2: %3 -SetupFileMissing=A(z) %1 fjl hinyzik a telept knyvtrbl. Krem hrtsa el a problmt, vagy szerezzen be egy msik pldnyt a programbl! -SetupFileCorrupt=A teleptsi fjlok srltek. Krem, szerezzen be j msolatot a programbl! -SetupFileCorruptOrWrongVer=A teleptsi fjlok srltek, vagy inkompatibilisek a telept ezen verzijval. Hrtsa el a problmt, vagy szerezzen be egy msik pldnyt a programbl! -InvalidParameter=A parancssorba tadott paramter rvnytelen:%n%n%1 -SetupAlreadyRunning=A Telept mr fut. -WindowsVersionNotSupported=A program nem tmogatja a Windows ezen verzijt. -WindowsServicePackRequired=A program futtatshoz %1 Service Pack %2 vagy jabb szksges. -NotOnThisPlatform=Ez a program nem futtathat %1 alatt. +SetupFileMissing=A(z) %1 fájl hiányzik a telepítő könyvtárából. Kérem hárítsa el a problémát, vagy szerezzen be egy másik példányt a programból! +SetupFileCorrupt=A telepítési fájlok sérültek. Kérem, szerezzen be új másolatot a programból! +SetupFileCorruptOrWrongVer=A telepítési fájlok sérültek, vagy inkompatibilisek a telepítő ezen verziójával. Hárítsa el a problémát, vagy szerezzen be egy másik példányt a programból! +InvalidParameter=A parancssorba átadott paraméter érvénytelen:%n%n%1 +SetupAlreadyRunning=A Telepítő már fut. +WindowsVersionNotSupported=A program nem támogatja a Windows ezen verzióját. +WindowsServicePackRequired=A program futtatásához %1 Service Pack %2 vagy újabb szükséges. +NotOnThisPlatform=Ez a program nem futtatható %1 alatt. OnlyOnThisPlatform=Ezt a programot %1 alatt kell futtatni. -OnlyOnTheseArchitectures=A program kizrlag a kvetkez processzor architektrkhoz tervezett Windows-on telepthet:%n%n%1 -WinVersionTooLowError=A program futtatshoz %1 %2 verzija vagy ksbbi szksges. -WinVersionTooHighError=Ez a program nem telepthet %1 %2 vagy ksbbire. -AdminPrivilegesRequired=Csak rendszergazdai mdban telepthet ez a program. -PowerUserPrivilegesRequired=Csak rendszergazdaknt vagy kiemelt felhasznlknt telepthet ez a program. -SetupAppRunningError=A telept gy szlelte %1 jelenleg fut.%n%nZrja be az sszes pldnyt, majd kattintson az 'OK'-ra a folytatshoz, vagy a 'Mgse'-re a kilpshez. -UninstallAppRunningError=Az eltvolt gy szlelte %1 jelenleg fut.%n%nZrja be az sszes pldnyt, majd kattintson az 'OK'-ra a folytatshoz, vagy a 'Mgse'-re a kilpshez. +OnlyOnTheseArchitectures=A program kizárólag a következő processzor architektúrákhoz tervezett Windows-on telepíthető:%n%n%1 +WinVersionTooLowError=A program futtatásához %1 %2 verziója vagy későbbi szükséges. +WinVersionTooHighError=Ez a program nem telepíthető %1 %2 vagy későbbire. +AdminPrivilegesRequired=Csak rendszergazdai módban telepíthető ez a program. +PowerUserPrivilegesRequired=Csak rendszergazdaként vagy kiemelt felhasználóként telepíthető ez a program. +SetupAppRunningError=A telepítő úgy észlelte %1 jelenleg fut.%n%nZárja be az összes példányt, majd kattintson az 'OK'-ra a folytatáshoz, vagy a 'Mégse'-re a kilépéshez. +UninstallAppRunningError=Az eltávolító úgy észlelte %1 jelenleg fut.%n%nZárja be az összes példányt, majd kattintson az 'OK'-ra a folytatáshoz, vagy a 'Mégse'-re a kilépéshez. ; *** Startup questions -PrivilegesRequiredOverrideTitle=Teleptsi md kivlasztsa -PrivilegesRequiredOverrideInstruction=Vlasszon teleptsi mdot -PrivilegesRequiredOverrideText1=%1 telepthet az sszes felhasznlnak (rendszergazdai jogok szksgesek), vagy csak magnak. -PrivilegesRequiredOverrideText2=%1 csak magnak telepthet, vagy az sszes felhasznlnak (rendszergazdai jogok szksgesek). -PrivilegesRequiredOverrideAllUsers=Telepts &mindenkinek -PrivilegesRequiredOverrideAllUsersRecommended=Telepts &mindenkinek (ajnlott) -PrivilegesRequiredOverrideCurrentUser=Telepts csak &nekem -PrivilegesRequiredOverrideCurrentUserRecommended=Telepts csak &nekem (ajnlott) +PrivilegesRequiredOverrideTitle=Telepítési mód kiválasztása +PrivilegesRequiredOverrideInstruction=Válasszon telepítési módot +PrivilegesRequiredOverrideText1=%1 telepíthető az összes felhasználónak (rendszergazdai jogok szükségesek), vagy csak magának. +PrivilegesRequiredOverrideText2=%1 csak magának telepíthető, vagy az összes felhasználónak (rendszergazdai jogok szükségesek). +PrivilegesRequiredOverrideAllUsers=Telepítés &mindenkinek +PrivilegesRequiredOverrideAllUsersRecommended=Telepítés &mindenkinek (ajánlott) +PrivilegesRequiredOverrideCurrentUser=Telepítés csak &nekem +PrivilegesRequiredOverrideCurrentUserRecommended=Telepítés csak &nekem (ajánlott) ; *** Misc. errors -ErrorCreatingDir=A Telept nem tudta ltrehozni a(z) "%1" knyvtrat -ErrorTooManyFilesInDir=Nem hozhat ltre fjl a(z) "%1" knyvtrban, mert az mr tl sok fjlt tartalmaz +ErrorCreatingDir=A Telepítő nem tudta létrehozni a(z) "%1" könyvtárat +ErrorTooManyFilesInDir=Nem hozható létre fájl a(z) "%1" könyvtárban, mert az már túl sok fájlt tartalmaz ; *** Setup common messages -ExitSetupTitle=Kilps a teleptbl -ExitSetupMessage=A telepts mg folyamatban van. Ha most kilp, a program nem kerl teleptsre.%n%nMsik alkalommal is futtathat a telepts befejezshez%n%nKilp a teleptbl? -AboutSetupMenuItem=&Nvjegy... -AboutSetupTitle=Telept nvjegye -AboutSetupMessage=%1 %2 verzi%n%3%n%nAz %1 honlapja:%n%4 +ExitSetupTitle=Kilépés a telepítőből +ExitSetupMessage=A telepítés még folyamatban van. Ha most kilép, a program nem kerül telepítésre.%n%nMásik alkalommal is futtatható a telepítés befejezéséhez%n%nKilép a telepítőből? +AboutSetupMenuItem=&Névjegy... +AboutSetupTitle=Telepítő névjegye +AboutSetupMessage=%1 %2 verzió%n%3%n%nAz %1 honlapja:%n%4 AboutSetupNote= TranslatorNote= ; *** Buttons ButtonBack=< &Vissza -ButtonNext=&Tovbb > -ButtonInstall=&Telept +ButtonNext=&Tovább > +ButtonInstall=&Telepít ButtonOK=OK -ButtonCancel=Mgse +ButtonCancel=Mégse ButtonYes=&Igen ButtonYesToAll=&Mindet ButtonNo=&Nem ButtonNoToAll=&Egyiket se -ButtonFinish=&Befejezs -ButtonBrowse=&Tallzs... -ButtonWizardBrowse=T&allzs... -ButtonNewFolder=j &knyvtr +ButtonFinish=&Befejezés +ButtonBrowse=&Tallózás... +ButtonWizardBrowse=T&allózás... +ButtonNewFolder=Új &könyvtár ; *** "Select Language" dialog messages -SelectLanguageTitle=Telept nyelvi bellts -SelectLanguageLabel=Vlassza ki a telepts alatt hasznlt nyelvet. +SelectLanguageTitle=Telepítő nyelvi beállítás +SelectLanguageLabel=Válassza ki a telepítés alatt használt nyelvet. ; *** Common wizard text -ClickNext=A folytatshoz kattintson a 'Tovbb'-ra, a kilpshez a 'Mgse'-re. +ClickNext=A folytatáshoz kattintson a 'Tovább'-ra, a kilépéshez a 'Mégse'-re. BeveledLabel= -BrowseDialogTitle=Vlasszon knyvtrt -BrowseDialogLabel=Vlasszon egy knyvtrat az albbi listbl, majd kattintson az 'OK'-ra. -NewFolderName=j knyvtr +BrowseDialogTitle=Válasszon könyvtárt +BrowseDialogLabel=Válasszon egy könyvtárat az alábbi listából, majd kattintson az 'OK'-ra. +NewFolderName=Új könyvtár ; *** "Welcome" wizard page -WelcomeLabel1=dvzli a(z) [name] Teleptvarzslja. -WelcomeLabel2=A(z) [name/ver] teleptsre kerl a szmtgpn.%n%nAjnlott minden, egyb fut alkalmazs bezrsa a folytats eltt. +WelcomeLabel1=Üdvözli a(z) [name] Telepítővarázslója. +WelcomeLabel2=A(z) [name/ver] telepítésre kerül a számítógépén.%n%nAjánlott minden, egyéb futó alkalmazás bezárása a folytatás előtt. ; *** "Password" wizard page -WizardPassword=Jelsz -PasswordLabel1=Ez a telepts jelszval vdett. -PasswordLabel3=Krem adja meg a jelszt, majd kattintson a 'Tovbb'-ra. A jelszavak kis- s nagy bet rzkenyek lehetnek. -PasswordEditLabel=&Jelsz: -IncorrectPassword=Az n ltal megadott jelsz helytelen. Prblja jra. +WizardPassword=Jelszó +PasswordLabel1=Ez a telepítés jelszóval védett. +PasswordLabel3=Kérem adja meg a jelszót, majd kattintson a 'Tovább'-ra. A jelszavak kis- és nagy betű érzékenyek lehetnek. +PasswordEditLabel=&Jelszó: +IncorrectPassword=Az ön által megadott jelszó helytelen. Próbálja újra. ; *** "License Agreement" wizard page -WizardLicense=Licencszerzds -LicenseLabel=Olvassa el figyelmesen az informcikat folytats eltt. -LicenseLabel3=Krem, olvassa el az albbi licencszerzdst. A telepts folytatshoz, el kell fogadnia a szerzdst. -LicenseAccepted=&Elfogadom a szerzdst -LicenseNotAccepted=&Nem fogadom el a szerzdst +WizardLicense=Licencszerződés +LicenseLabel=Olvassa el figyelmesen az információkat folytatás előtt. +LicenseLabel3=Kérem, olvassa el az alábbi licencszerződést. A telepítés folytatásához, el kell fogadnia a szerződést. +LicenseAccepted=&Elfogadom a szerződést +LicenseNotAccepted=&Nem fogadom el a szerződést ; *** "Information" wizard pages -WizardInfoBefore=Informcik -InfoBeforeLabel=Olvassa el a kvetkez fontos informcikat a folytats eltt. -InfoBeforeClickLabel=Ha kszen ll, kattintson a 'Tovbb'-ra. -WizardInfoAfter=Informcik -InfoAfterLabel=Olvassa el a kvetkez fontos informcikat a folytats eltt. -InfoAfterClickLabel=Ha kszen ll, kattintson a 'Tovbb'-ra. +WizardInfoBefore=Információk +InfoBeforeLabel=Olvassa el a következő fontos információkat a folytatás előtt. +InfoBeforeClickLabel=Ha készen áll, kattintson a 'Tovább'-ra. +WizardInfoAfter=Információk +InfoAfterLabel=Olvassa el a következő fontos információkat a folytatás előtt. +InfoAfterClickLabel=Ha készen áll, kattintson a 'Tovább'-ra. ; *** "User Information" wizard page -WizardUserInfo=Felhasznl adatai -UserInfoDesc=Krem, adja meg az adatait -UserInfoName=&Felhasznlnv: +WizardUserInfo=Felhasználó adatai +UserInfoDesc=Kérem, adja meg az adatait +UserInfoName=&Felhasználónév: UserInfoOrg=&Szervezet: -UserInfoSerial=&Sorozatszm: +UserInfoSerial=&Sorozatszám: UserInfoNameRequired=Meg kell adnia egy nevet. ; *** "Select Destination Location" wizard page -WizardSelectDir=Vlasszon clknyvtrat -SelectDirDesc=Hova telepljn a(z) [name]? -SelectDirLabel3=A(z) [name] az albbi knyvtrba lesz teleptve. -SelectDirBrowseLabel=A folytatshoz, kattintson a 'Tovbb'-ra. Ha msik knyvtrat vlasztana, kattintson a 'Tallzs'-ra. -DiskSpaceGBLabel=At least [gb] GB szabad terletre van szksg. -DiskSpaceMBLabel=Legalbb [mb] MB szabad terletre van szksg. -CannotInstallToNetworkDrive=A Telept nem tud hlzati meghajtra telepteni. -CannotInstallToUNCPath=A Telept nem tud hlzati UNC elrsi tra telepteni. -InvalidPath=Teljes tvonalat adjon meg, a meghajt betjelvel; pldul:%n%nC:\Alkalmazs%n%nvagy egy hlzati tvonalat a kvetkez alakban:%n%n\\kiszolgl\megoszts -InvalidDrive=A kivlasztott meghajt vagy hlzati megoszts nem ltezik vagy nem elrhet. Vlasszon egy msikat. -DiskSpaceWarningTitle=Nincs elg szabad terlet -DiskSpaceWarning=A Teleptnek legalbb %1 KB szabad lemezterletre van szksge, viszont a kivlasztott meghajtn csupn %2 KB ll rendelkezsre.%n%nMindenkppen folytatja? -DirNameTooLong=A knyvtr neve vagy az tvonal tl hossz. -InvalidDirName=A knyvtr neve rvnytelen. -BadDirName32=A knyvtrak nevei ezen karakterek egyikt sem tartalmazhatjk:%n%n%1 -DirExistsTitle=A knyvtr mr ltezik -DirExists=A knyvtr:%n%n%1%n%nmr ltezik. Mindenkpp ide akar telepteni? -DirDoesntExistTitle=A knyvtr nem ltezik -DirDoesntExist=A knyvtr:%n%n%1%n%nnem ltezik. Szeretn ltrehozni? +WizardSelectDir=Válasszon célkönyvtárat +SelectDirDesc=Hova települjön a(z) [name]? +SelectDirLabel3=A(z) [name] az alábbi könyvtárba lesz telepítve. +SelectDirBrowseLabel=A folytatáshoz, kattintson a 'Tovább'-ra. Ha másik könyvtárat választana, kattintson a 'Tallózás'-ra. +DiskSpaceGBLabel=At least [gb] GB szabad területre van szükség. +DiskSpaceMBLabel=Legalább [mb] MB szabad területre van szükség. +CannotInstallToNetworkDrive=A Telepítő nem tud hálózati meghajtóra telepíteni. +CannotInstallToUNCPath=A Telepítő nem tud hálózati UNC elérési útra telepíteni. +InvalidPath=Teljes útvonalat adjon meg, a meghajtó betűjelével; például:%n%nC:\Alkalmazás%n%nvagy egy hálózati útvonalat a következő alakban:%n%n\\kiszolgáló\megosztás +InvalidDrive=A kiválasztott meghajtó vagy hálózati megosztás nem létezik vagy nem elérhető. Válasszon egy másikat. +DiskSpaceWarningTitle=Nincs elég szabad terület +DiskSpaceWarning=A Telepítőnek legalább %1 KB szabad lemezterületre van szüksége, viszont a kiválasztott meghajtón csupán %2 KB áll rendelkezésre.%n%nMindenképpen folytatja? +DirNameTooLong=A könyvtár neve vagy az útvonal túl hosszú. +InvalidDirName=A könyvtár neve érvénytelen. +BadDirName32=A könyvtárak nevei ezen karakterek egyikét sem tartalmazhatják:%n%n%1 +DirExistsTitle=A könyvtár már létezik +DirExists=A könyvtár:%n%n%1%n%nmár létezik. Mindenképp ide akar telepíteni? +DirDoesntExistTitle=A könyvtár nem létezik +DirDoesntExist=A könyvtár:%n%n%1%n%nnem létezik. Szeretné létrehozni? ; *** "Select Components" wizard page -WizardSelectComponents=sszetevk kivlasztsa -SelectComponentsDesc=Mely sszetevk kerljenek teleptsre? -SelectComponentsLabel2=Jellje ki a teleptend sszetevket; trlje a telepteni nem kvnt sszetevket. Kattintson a 'Tovbb'-ra, ha kszen ll a folytatsra. -FullInstallation=Teljes telepts +WizardSelectComponents=Összetevők kiválasztása +SelectComponentsDesc=Mely összetevők kerüljenek telepítésre? +SelectComponentsLabel2=Jelölje ki a telepítendő összetevőket; törölje a telepíteni nem kívánt összetevőket. Kattintson a 'Tovább'-ra, ha készen áll a folytatásra. +FullInstallation=Teljes telepítés ; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language) -CompactInstallation=Szoksos telepts -CustomInstallation=Egyni telepts -NoUninstallWarningTitle=Ltez sszetev -NoUninstallWarning=A telept gy tallta, hogy a kvetkez sszetevk mr teleptve vannak a szmtgpre:%n%n%1%n%nEzen sszetevk kijellsnek trlse, nem tvoltja el azokat a szmtgprl.%n%nMindenkppen folytatja? +CompactInstallation=Szokásos telepítés +CustomInstallation=Egyéni telepítés +NoUninstallWarningTitle=Létező összetevő +NoUninstallWarning=A telepítő úgy találta, hogy a következő összetevők már telepítve vannak a számítógépre:%n%n%1%n%nEzen összetevők kijelölésének törlése, nem távolítja el azokat a számítógépről.%n%nMindenképpen folytatja? ComponentSize1=%1 KB ComponentSize2=%1 MB -ComponentsDiskSpaceMBLabel=A jelenlegi kijells legalbb [gb] GB lemezterletet ignyel. -ComponentsDiskSpaceMBLabel=A jelenlegi kijells legalbb [mb] MB lemezterletet ignyel. +ComponentsDiskSpaceGBLabel=A jelenlegi kijelölés legalább [gb] GB lemezterületet igényel. +ComponentsDiskSpaceMBLabel=A jelenlegi kijelölés legalább [mb] MB lemezterületet igényel. ; *** "Select Additional Tasks" wizard page -WizardSelectTasks=Tovbbi feladatok -SelectTasksDesc=Mely kiegszt feladatok kerljenek vgrehajtsra? -SelectTasksLabel2=Jellje ki, mely kiegszt feladatokat hajtsa vgre a Telept a(z) [name] teleptse sorn, majd kattintson a 'Tovbb'-ra. +WizardSelectTasks=További feladatok +SelectTasksDesc=Mely kiegészítő feladatok kerüljenek végrehajtásra? +SelectTasksLabel2=Jelölje ki, mely kiegészítő feladatokat hajtsa végre a Telepítő a(z) [name] telepítése során, majd kattintson a 'Tovább'-ra. ; *** "Select Start Menu Folder" wizard page -WizardSelectProgramGroup=Start Men knyvtra -SelectStartMenuFolderDesc=Hova helyezze a Telept a program parancsikonjait? -SelectStartMenuFolderLabel3=A Telept a program parancsikonjait a Start men kvetkez mappjban fogja ltrehozni. -SelectStartMenuFolderBrowseLabel=A folytatshoz kattintson a 'Tovbb'-ra. Ha msik mappt vlasztana, kattintson a 'Tallzs'-ra. +WizardSelectProgramGroup=Start Menü könyvtára +SelectStartMenuFolderDesc=Hova helyezze a Telepítő a program parancsikonjait? +SelectStartMenuFolderLabel3=A Telepítő a program parancsikonjait a Start menü következő mappájában fogja létrehozni. +SelectStartMenuFolderBrowseLabel=A folytatáshoz kattintson a 'Tovább'-ra. Ha másik mappát választana, kattintson a 'Tallózás'-ra. MustEnterGroupName=Meg kell adnia egy mappanevet. -GroupNameTooLong=A knyvtr neve vagy az tvonal tl hossz. -InvalidGroupName=A knyvtr neve rvnytelen. -BadGroupName=A knyvtrak nevei ezen karakterek egyikt sem tartalmazhatjk:%n%n%1 -NoProgramGroupCheck2=&Ne hozzon ltre mappt a Start menben +GroupNameTooLong=A könyvtár neve vagy az útvonal túl hosszú. +InvalidGroupName=A könyvtár neve érvénytelen. +BadGroupName=A könyvtárak nevei ezen karakterek egyikét sem tartalmazhatják:%n%n%1 +NoProgramGroupCheck2=&Ne hozzon létre mappát a Start menüben ; *** "Ready to Install" wizard page -WizardReady=Kszen llunk a teleptsre -ReadyLabel1=A Telept kszen ll, a(z) [name] szmtgpre teleptshez. -ReadyLabel2a=Kattintson a 'Telepts'-re a folytatshoz, vagy a "Vissza"-ra a belltsok ttekintshez vagy megvltoztatshoz. -ReadyLabel2b=Kattintson a 'Telepts'-re a folytatshoz. -ReadyMemoUserInfo=Felhasznl adatai: -ReadyMemoDir=Telepts clknyvtra: -ReadyMemoType=Telepts tpusa: -ReadyMemoComponents=Vlasztott sszetevk: -ReadyMemoGroup=Start men mappja: -ReadyMemoTasks=Kiegszt feladatok: +WizardReady=Készen állunk a telepítésre +ReadyLabel1=A Telepítő készen áll, a(z) [name] számítógépre telepítéshez. +ReadyLabel2a=Kattintson a 'Telepítés'-re a folytatáshoz, vagy a "Vissza"-ra a beállítások áttekintéséhez vagy megváltoztatásához. +ReadyLabel2b=Kattintson a 'Telepítés'-re a folytatáshoz. +ReadyMemoUserInfo=Felhasználó adatai: +ReadyMemoDir=Telepítés célkönyvtára: +ReadyMemoType=Telepítés típusa: +ReadyMemoComponents=Választott összetevők: +ReadyMemoGroup=Start menü mappája: +ReadyMemoTasks=Kiegészítő feladatok: ; *** "Preparing to Install" wizard page -WizardPreparing=Felkszls a teleptsre -PreparingDesc=A Telept felkszl a(z) [name] szmtgpre trtn teleptshez. -PreviousInstallNotCompleted=gy korbbi program teleptse/eltvoltsa nem fejezdtt be. jra kell indtania a szmtgpt a msik telepts befejezshez.%n%nA szmtgpe jraindtsa utn ismt futtassa a Teleptt a(z) [name] teleptsnek befejezshez. -CannotContinue=A telepts nem folytathat. A kilpshez kattintson a 'Mgse'-re -ApplicationsFound=A kvetkez alkalmazsok olyan fjlokat hasznlnak, amelyeket a Teleptnek frissteni kell. Ajnlott, hogy engedlyezze a Teleptnek ezen alkalmazsok automatikus bezrst. -ApplicationsFound2=A kvetkez alkalmazsok olyan fjlokat hasznlnak, amelyeket a Teleptnek frissteni kell. Ajnlott, hogy engedlyezze a Teleptnek ezen alkalmazsok automatikus bezrst. A telepts befejezse utn a Telept megksrli az alkalmazsok jraindtst. -CloseApplications=&Alkalmazsok automatikus bezrsa -DontCloseApplications=&Ne zrja be az alkalmazsokat -ErrorCloseApplications=A Telept nem tudott minden alkalmazst automatikusan bezrni. A folytats eltt ajnlott minden, a Telept ltal frisstend fjlokat hasznl alkalmazst bezrni. -PrepareToInstallNeedsRestart=A teleptnek jra kell indtania a szmtgpet. jraindtst kveten, futtassa jbl a teleptt, a [name] teleptsnek befejezshez .%n%njra szeretn indtani most a szmtgpet? +WizardPreparing=Felkészülés a telepítésre +PreparingDesc=A Telepítő felkészül a(z) [name] számítógépre történő telepítéshez. +PreviousInstallNotCompleted=gy korábbi program telepítése/eltávolítása nem fejeződött be. Újra kell indítania a számítógépét a másik telepítés befejezéséhez.%n%nA számítógépe újraindítása után ismét futtassa a Telepítőt a(z) [name] telepítésének befejezéséhez. +CannotContinue=A telepítés nem folytatható. A kilépéshez kattintson a 'Mégse'-re +ApplicationsFound=A következő alkalmazások olyan fájlokat használnak, amelyeket a Telepítőnek frissíteni kell. Ajánlott, hogy engedélyezze a Telepítőnek ezen alkalmazások automatikus bezárását. +ApplicationsFound2=A következő alkalmazások olyan fájlokat használnak, amelyeket a Telepítőnek frissíteni kell. Ajánlott, hogy engedélyezze a Telepítőnek ezen alkalmazások automatikus bezárását. A telepítés befejezése után a Telepítő megkísérli az alkalmazások újraindítását. +CloseApplications=&Alkalmazások automatikus bezárása +DontCloseApplications=&Ne zárja be az alkalmazásokat +ErrorCloseApplications=A Telepítő nem tudott minden alkalmazást automatikusan bezárni. A folytatás előtt ajánlott minden, a Telepítő által frissítendő fájlokat használó alkalmazást bezárni. +PrepareToInstallNeedsRestart=A telepítőnek újra kell indítania a számítógépet. Újraindítást követően, futtassa újból a telepítőt, a [name] telepítésének befejezéséhez .%n%nÚjra szeretné indítani most a számítógépet? ; *** "Installing" wizard page -WizardInstalling=Telepts -InstallingLabel=Krem vrjon, amg a(z) [name] teleptse zajlik. +WizardInstalling=Telepítés +InstallingLabel=Kérem várjon, amíg a(z) [name] telepítése zajlik. ; *** "Setup Completed" wizard page -FinishedHeadingLabel=A(z) [name] teleptsnek befejezse -FinishedLabelNoIcons=A Telept vgzett a(z) [name] teleptsvel. -FinishedLabel=A Telept vgzett a(z) [name] teleptsvel. Az alkalmazst a ltrehozott ikonok kivlasztsval indthatja. -ClickFinish=Kattintson a 'Befejezs'-re a kilpshez. -FinishedRestartLabel=A(z) [name] teleptsnek befejezshez jra kell indtani a szmtgpet. jraindtja most? -FinishedRestartMessage=A(z) [name] teleptsnek befejezshez, a Teleptnek jra kell indtani a szmtgpet.%n%njraindtja most? -ShowReadmeCheck=Igen, szeretnm elolvasni a FONTOS fjlt -YesRadio=&Igen, jraindts most -NoRadio=&Nem, ksbb indtom jra +FinishedHeadingLabel=A(z) [name] telepítésének befejezése +FinishedLabelNoIcons=A Telepítő végzett a(z) [name] telepítésével. +FinishedLabel=A Telepítő végzett a(z) [name] telepítésével. Az alkalmazást a létrehozott ikonok kiválasztásával indíthatja. +ClickFinish=Kattintson a 'Befejezés'-re a kilépéshez. +FinishedRestartLabel=A(z) [name] telepítésének befejezéséhez újra kell indítani a számítógépet. Újraindítja most? +FinishedRestartMessage=A(z) [name] telepítésének befejezéséhez, a Telepítőnek újra kell indítani a számítógépet.%n%nÚjraindítja most? +ShowReadmeCheck=Igen, szeretném elolvasni a FONTOS fájlt +YesRadio=&Igen, újraindítás most +NoRadio=&Nem, később indítom újra ; used for example as 'Run MyProg.exe' -RunEntryExec=%1 futtatsa +RunEntryExec=%1 futtatása ; used for example as 'View Readme.txt' -RunEntryShellExec=%1 megtekintse +RunEntryShellExec=%1 megtekintése ; *** "Setup Needs the Next Disk" stuff -ChangeDiskTitle=A Teleptnek szksge van a kvetkez lemezre -SelectDiskLabel2=Helyezze be a(z) %1. lemezt s kattintson az 'OK'-ra.%n%nHa a fjlok a lemez egy a megjelentettl klnbz mappjban tallhatk, rja be a helyes tvonalat vagy kattintson a 'Tallzs'-ra. -PathLabel=&tvonal: -FileNotInDir2=A(z) "%1" fjl nem tallhat a kvetkez helyen: "%2". Helyezze be a megfelel lemezt vagy vlasszon egy msik mappt. -SelectDirectoryLabel=Adja meg a kvetkez lemez helyt. +ChangeDiskTitle=A Telepítőnek szüksége van a következő lemezre +SelectDiskLabel2=Helyezze be a(z) %1. lemezt és kattintson az 'OK'-ra.%n%nHa a fájlok a lemez egy a megjelenítettől különböző mappájában találhatók, írja be a helyes útvonalat vagy kattintson a 'Tallózás'-ra. +PathLabel=Ú&tvonal: +FileNotInDir2=A(z) "%1" fájl nem található a következő helyen: "%2". Helyezze be a megfelelő lemezt vagy válasszon egy másik mappát. +SelectDirectoryLabel=Adja meg a következő lemez helyét. ; *** Installation phase messages -SetupAborted=A telepts nem fejezdtt be.%n%nHrtsa el a hibt s futtassa jbl a Teleptt. -AbortRetryIgnoreSelectAction=Vlasszon mveletet -AbortRetryIgnoreRetry=&jra -AbortRetryIgnoreIgnore=&Hiba elvetse s folytats -AbortRetryIgnoreCancel=Telepts megszaktsa +SetupAborted=A telepítés nem fejeződött be.%n%nHárítsa el a hibát és futtassa újból a Telepítőt. +AbortRetryIgnoreSelectAction=Válasszon műveletet +AbortRetryIgnoreRetry=&Újra +AbortRetryIgnoreIgnore=&Hiba elvetése és folytatás +AbortRetryIgnoreCancel=Telepítés megszakítása ; *** Installation status messages -StatusClosingApplications=Alkalmazsok bezrsa... -StatusCreateDirs=Knyvtrak ltrehozsa... -StatusExtractFiles=Fjlok kibontsa... -StatusCreateIcons=Parancsikonok ltrehozsa... -StatusCreateIniEntries=INI bejegyzsek ltrehozsa... -StatusCreateRegistryEntries=Rendszerler bejegyzsek ltrehozsa... -StatusRegisterFiles=Fjlok regisztrlsa... -StatusSavingUninstall=Eltvolt informcik mentse... -StatusRunProgram=Telepts befejezse... -StatusRestartingApplications=Alkalmazsok jraindtsa... -StatusRollback=Vltoztatsok visszavonsa... +StatusClosingApplications=Alkalmazások bezárása... +StatusCreateDirs=Könyvtárak létrehozása... +StatusExtractFiles=Fájlok kibontása... +StatusCreateIcons=Parancsikonok létrehozása... +StatusCreateIniEntries=INI bejegyzések létrehozása... +StatusCreateRegistryEntries=Rendszerleíró bejegyzések létrehozása... +StatusRegisterFiles=Fájlok regisztrálása... +StatusSavingUninstall=Eltávolító információk mentése... +StatusRunProgram=Telepítés befejezése... +StatusRestartingApplications=Alkalmazások újraindítása... +StatusRollback=Változtatások visszavonása... ; *** Misc. errors -ErrorInternal2=Bels hiba: %1 +ErrorInternal2=Belső hiba: %1 ErrorFunctionFailedNoCode=Sikertelen %1 -ErrorFunctionFailed=Sikertelen %1; kd: %2 -ErrorFunctionFailedWithMessage=Sikertelen %1; kd: %2.%n%3 -ErrorExecutingProgram=Nem hajthat vgre a fjl:%n%1 +ErrorFunctionFailed=Sikertelen %1; kód: %2 +ErrorFunctionFailedWithMessage=Sikertelen %1; kód: %2.%n%3 +ErrorExecutingProgram=Nem hajtható végre a fájl:%n%1 ; *** Registry errors -ErrorRegOpenKey=Nem nyithat meg a rendszerler kulcs:%n%1\%2 -ErrorRegCreateKey=Nem hozhat ltre a rendszerler kulcs:%n%1\%2 -ErrorRegWriteKey=Nem mdosthat a rendszerler kulcs:%n%1\%2 +ErrorRegOpenKey=Nem nyitható meg a rendszerleíró kulcs:%n%1\%2 +ErrorRegCreateKey=Nem hozható létre a rendszerleíró kulcs:%n%1\%2 +ErrorRegWriteKey=Nem módosítható a rendszerleíró kulcs:%n%1\%2 ; *** INI errors -ErrorIniEntry=Bejegyzs ltrehozsa sikertelen a kvetkez INI fjlban: "%1". +ErrorIniEntry=Bejegyzés létrehozása sikertelen a következő INI fájlban: "%1". ; *** File copying errors -FileAbortRetryIgnoreSkipNotRecommended=&Fjl kihagysa (nem ajnlott) -FileAbortRetryIgnoreIgnoreNotRecommended=&Hiba elvetse s folytats (nem ajnlott) -SourceIsCorrupted=A forrsfjl megsrlt -SourceDoesntExist=A(z) "%1" forrsfjl nem ltezik -ExistingFileReadOnly2=A fjl csak olvashatknt van jellve. -ExistingFileReadOnlyRetry=Csak &olvashat tulajdonsg eltvoltsa s jra prblkozs -ExistingFileReadOnlyKeepExisting=&Ltez fjl megtartsa -ErrorReadingExistingDest=Hiba lpett fel a fjl olvassa kzben: -FileExists=A fjl mr ltezik.%n%nFell kvnja rni? -ExistingFileNewer=A ltez fjl jabb a teleptsre kerlnl. Ajnlott a ltez fjl megtartsa.%n%nMeg kvnja tartani a ltez fjlt? -ErrorChangingAttr=Hiba lpett fel a fjl attribtumnak mdostsa kzben: -ErrorCreatingTemp=Hiba lpett fel a fjl teleptsi knyvtrban trtn ltrehozsa kzben: -ErrorReadingSource=Hiba lpett fel a forrsfjl olvassa kzben: -ErrorCopying=Hiba lpett fel a fjl msolsa kzben: -ErrorReplacingExistingFile=Hiba lpett fel a ltez fjl cserje kzben: -ErrorRestartReplace=A fjl cserje az jraindts utn sikertelen volt: -ErrorRenamingTemp=Hiba lpett fel fjl teleptsi knyvtrban trtn tnevezse kzben: -ErrorRegisterServer=Nem lehet regisztrlni a DLL-t/OCX-et: %1 -ErrorRegSvr32Failed=Sikertelen RegSvr32. A visszaadott kd: %1 -ErrorRegisterTypeLib=Nem lehet regisztrlni a tpustrat: %1 +FileAbortRetryIgnoreSkipNotRecommended=&Fájl kihagyása (nem ajánlott) +FileAbortRetryIgnoreIgnoreNotRecommended=&Hiba elvetése és folytatás (nem ajánlott) +SourceIsCorrupted=A forrásfájl megsérült +SourceDoesntExist=A(z) "%1" forrásfájl nem létezik +ExistingFileReadOnly2=A fájl csak olvashatóként van jelölve. +ExistingFileReadOnlyRetry=Csak &olvasható tulajdonság eltávolítása és újra próbálkozás +ExistingFileReadOnlyKeepExisting=&Létező fájl megtartása +ErrorReadingExistingDest=Hiba lépett fel a fájl olvasása közben: +FileExists=A fájl már létezik.%n%nFelül kívánja írni? +ExistingFileNewer=A létező fájl újabb a telepítésre kerülőnél. Ajánlott a létező fájl megtartása.%n%nMeg kívánja tartani a létező fájlt? +ErrorChangingAttr=Hiba lépett fel a fájl attribútumának módosítása közben: +ErrorCreatingTemp=Hiba lépett fel a fájl telepítési könyvtárban történő létrehozása közben: +ErrorReadingSource=Hiba lépett fel a forrásfájl olvasása közben: +ErrorCopying=Hiba lépett fel a fájl másolása közben: +ErrorReplacingExistingFile=Hiba lépett fel a létező fájl cseréje közben: +ErrorRestartReplace=A fájl cseréje az újraindítás után sikertelen volt: +ErrorRenamingTemp=Hiba lépett fel fájl telepítési könyvtárban történő átnevezése közben: +ErrorRegisterServer=Nem lehet regisztrálni a DLL-t/OCX-et: %1 +ErrorRegSvr32Failed=Sikertelen RegSvr32. A visszaadott kód: %1 +ErrorRegisterTypeLib=Nem lehet regisztrálni a típustárat: %1 ; *** Uninstall display name markings ; used for example as 'My Program (32-bit)' @@ -314,53 +314,53 @@ UninstallDisplayNameMark=%1 (%2) UninstallDisplayNameMarks=%1 (%2, %3) UninstallDisplayNameMark32Bit=32-bit UninstallDisplayNameMark64Bit=64-bit -UninstallDisplayNameMarkAllUsers=Minden felhasznl -UninstallDisplayNameMarkCurrentUser=Jelenlegi felhasznl +UninstallDisplayNameMarkAllUsers=Minden felhasználó +UninstallDisplayNameMarkCurrentUser=Jelenlegi felhasználó ; *** Post-installation errors -ErrorOpeningReadme=Hiba lpett fel a FONTOS fjl megnyitsa kzben. -ErrorRestartingComputer=A Telept nem tudta jraindtani a szmtgpet. Indtsa jra kzileg. +ErrorOpeningReadme=Hiba lépett fel a FONTOS fájl megnyitása közben. +ErrorRestartingComputer=A Telepítő nem tudta újraindítani a számítógépet. Indítsa újra kézileg. ; *** Uninstaller messages -UninstallNotFound=A(z) "%1" fjl nem ltezik. Nem tvolthat el. -UninstallOpenError=A(z) "%1" fjl nem nyithat meg. Nem tvolthat el. -UninstallUnsupportedVer=A(z) "%1" eltvoltsi naplfjl formtumt nem tudja felismerni az eltvolt jelen verzija. Az eltvolts nem folytathat -UninstallUnknownEntry=Egy ismeretlen bejegyzs (%1) tallhat az eltvoltsi naplfjlban -ConfirmUninstall=Biztosan el kvnja tvoltani a(z) %1 programot s minden sszetevjt? -UninstallOnlyOnWin64=Ezt a teleptst csak 64-bites Windowson lehet eltvoltani. -OnlyAdminCanUninstall=Ezt a teleptst csak adminisztrcis jogokkal rendelkez felhasznl tvolthatja el. -UninstallStatusLabel=Legyen trelemmel, amg a(z) %1 szmtgprl trtn eltvoltsa befejezdik. -UninstalledAll=A(z) %1 sikeresen el lett tvoltva a szmtgprl. -UninstalledMost=A(z) %1 eltvoltsa befejezdtt.%n%nNhny elemet nem lehetett eltvoltani. Trlje kzileg. -UninstalledAndNeedsRestart=A(z) %1 eltvoltsnak befejezshez jra kell indtania a szmtgpt.%n%njraindtja most? -UninstallDataCorrupted=A(z) "%1" fjl srlt. Nem tvolthat el. +UninstallNotFound=A(z) "%1" fájl nem létezik. Nem távolítható el. +UninstallOpenError=A(z) "%1" fájl nem nyitható meg. Nem távolítható el. +UninstallUnsupportedVer=A(z) "%1" eltávolítási naplófájl formátumát nem tudja felismerni az eltávolító jelen verziója. Az eltávolítás nem folytatható +UninstallUnknownEntry=Egy ismeretlen bejegyzés (%1) található az eltávolítási naplófájlban +ConfirmUninstall=Biztosan el kívánja távolítani a(z) %1 programot és minden összetevőjét? +UninstallOnlyOnWin64=Ezt a telepítést csak 64-bites Windowson lehet eltávolítani. +OnlyAdminCanUninstall=Ezt a telepítést csak adminisztrációs jogokkal rendelkező felhasználó távolíthatja el. +UninstallStatusLabel=Legyen türelemmel, amíg a(z) %1 számítógépéről történő eltávolítása befejeződik. +UninstalledAll=A(z) %1 sikeresen el lett távolítva a számítógépről. +UninstalledMost=A(z) %1 eltávolítása befejeződött.%n%nNéhány elemet nem lehetett eltávolítani. Törölje kézileg. +UninstalledAndNeedsRestart=A(z) %1 eltávolításának befejezéséhez újra kell indítania a számítógépét.%n%nÚjraindítja most? +UninstallDataCorrupted=A(z) "%1" fájl sérült. Nem távolítható el. ; *** Uninstallation phase messages -ConfirmDeleteSharedFileTitle=Trli a megosztott fjlt? -ConfirmDeleteSharedFile2=A rendszer azt jelzi, hogy a kvetkez megosztott fjlra mr nincs szksge egyetlen programnak sem. Eltvoltja a megosztott fjlt?%n%nHa ms programok mg mindig hasznljk a megosztott fjlt, akkor az eltvoltsa utn lehet, hogy nem fognak megfelelen mkdni. Ha bizonytalan, vlassza a Nemet. A fjl megtartsa nem okoz problmt a rendszerben. -SharedFileNameLabel=Fjlnv: +ConfirmDeleteSharedFileTitle=Törli a megosztott fájlt? +ConfirmDeleteSharedFile2=A rendszer azt jelzi, hogy a következő megosztott fájlra már nincs szüksége egyetlen programnak sem. Eltávolítja a megosztott fájlt?%n%nHa más programok még mindig használják a megosztott fájlt, akkor az eltávolítása után lehet, hogy nem fognak megfelelően működni. Ha bizonytalan, válassza a Nemet. A fájl megtartása nem okoz problémát a rendszerben. +SharedFileNameLabel=Fájlnév: SharedFileLocationLabel=Helye: -WizardUninstalling=Eltvolts llapota -StatusUninstalling=%1 eltvoltsa... +WizardUninstalling=Eltávolítás állapota +StatusUninstalling=%1 eltávolítása... ; *** Shutdown block reasons -ShutdownBlockReasonInstallingApp=%1 teleptse. -ShutdownBlockReasonUninstallingApp=%1 eltvoltsa. +ShutdownBlockReasonInstallingApp=%1 telepítése. +ShutdownBlockReasonUninstallingApp=%1 eltávolítása. ; The custom messages below aren't used by Setup itself, but if you make ; use of them in your scripts, you'll want to translate them. [CustomMessages] -NameAndVersion=%1, verzi: %2 -AdditionalIcons=Tovbbi parancsikonok: -CreateDesktopIcon=&Asztali ikon ltrehozsa -CreateQuickLaunchIcon=&Gyorsindt parancsikon ltrehozsa +NameAndVersion=%1, verzió: %2 +AdditionalIcons=További parancsikonok: +CreateDesktopIcon=&Asztali ikon létrehozása +CreateQuickLaunchIcon=&Gyorsindító parancsikon létrehozása ProgramOnTheWeb=%1 az interneten -UninstallProgram=Eltvolts - %1 -LaunchProgram=Indts %1 -AssocFileExtension=A(z) %1 &trstsa a(z) %2 fjlkiterjesztssel -AssocingFileExtension=A(z) %1 trstsa a(z) %2 fjlkiterjesztssel... -AutoStartProgramGroupDescription=Indtpult: -AutoStartProgram=%1 automatikus indtsa -AddonHostProgramNotFound=A(z) %1 nem tallhat a kivlasztott knyvtrban.%n%nMindenkppen folytatja? +UninstallProgram=Eltávolítás - %1 +LaunchProgram=Indítás %1 +AssocFileExtension=A(z) %1 &társítása a(z) %2 fájlkiterjesztéssel +AssocingFileExtension=A(z) %1 társítása a(z) %2 fájlkiterjesztéssel... +AutoStartProgramGroupDescription=Indítópult: +AutoStartProgram=%1 automatikus indítása +AddonHostProgramNotFound=A(z) %1 nem található a kiválasztott könyvtárban.%n%nMindenképpen folytatja? diff --git a/build/win32/i18n/Default.ko.isl b/build/win32/i18n/Default.ko.isl index 0f1b1a7ccf521..dd71e0d75595b 100644 --- a/build/win32/i18n/Default.ko.isl +++ b/build/win32/i18n/Default.ko.isl @@ -1,11 +1,11 @@ -; *** Inno Setup version 6.0.0+ Korean messages *** +; *** Inno Setup version 6.0.0+ Korean messages *** ; -; 6.0.3+ Translator: SungDong Kim (acroedit@gmail.com) -; 5.5.3+ Translator: Domddol (domddol@gmail.com) -; Translation date: MAR 04, 2014 -; Contributors: Hansoo KIM (iryna7@gmail.com), Woong-Jae An (a183393@hanmail.net) -; Storage: http://www.jrsoftware.org/files/istrans/ -; ο ѱ Ģ ؼմϴ. +; ▒ 6.0.3+ Translator: SungDong Kim (acroedit@gmail.com) +; ▒ 5.5.3+ Translator: Domddol (domddol@gmail.com) +; ▒ Translation date: MAR 04, 2014 +; ▒ Contributors: Hansoo KIM (iryna7@gmail.com), Woong-Jae An (a183393@hanmail.net) +; ▒ Storage: http://www.jrsoftware.org/files/istrans/ +; ▒ 이 번역은 새로운 한국어 맞춤법 규칙을 준수합니다. ; Note: When translating this text, do not add periods (.) to the end of ; messages that didn't have them already, because on those messages Inno ; Setup adds the periods automatically (appending a period would result in @@ -16,7 +16,7 @@ ; understand the '[LangOptions] section' topic in the help file. LanguageName=Korean LanguageID=$0412 -LanguageCodePage=949 +LanguageCodePage=0 ; If the language you are translating to requires special font faces or ; sizes, uncomment any of the following entries and change them accordingly. ;DialogFontName= @@ -31,337 +31,337 @@ LanguageCodePage=949 [Messages] ; *** Application titles -SetupAppTitle=ġ -SetupWindowTitle=%1 ġ -UninstallAppTitle= -UninstallAppFullTitle=%1 +SetupAppTitle=설치 +SetupWindowTitle=%1 설치 +UninstallAppTitle=제거 +UninstallAppFullTitle=%1 제거 ; *** Misc. common -InformationTitle= -ConfirmTitle=Ȯ -ErrorTitle= +InformationTitle=정보 +ConfirmTitle=확인 +ErrorTitle=오류 ; *** SetupLdr messages -SetupLdrStartupMessage=%1() ġմϴ, Ͻðڽϱ? -LdrCannotCreateTemp=ӽ ϴ, ġ ߴմϴ -LdrCannotExecTemp=ӽ ϴ, ġ ߴմϴ +SetupLdrStartupMessage=%1을(를) 설치합니다, 계속하시겠습니까? +LdrCannotCreateTemp=임시 파일을 만들 수 없습니다, 설치를 중단합니다 +LdrCannotExecTemp=임시 폴더의 파일을 실행할 수 없습니다, 설치를 중단합니다 HelpTextNote= ; *** Startup error messages -LastErrorMessage=%1.%n%n %2: %3 -SetupFileMissing=%1 ʽϴ, ذ ų ο ġ α׷ Ͻñ ٶϴ. -SetupFileCorrupt=ġ ջǾϴ, ο ġ α׷ Ͻñ ٶϴ. -SetupFileCorruptOrWrongVer=ġ ջ̰ų ġ ȣȯ ʽϴ, ذ ų ο ġ α׷ Ͻñ ٶϴ. -InvalidParameter=߸ Ű Դϴ:%n%n%1 -SetupAlreadyRunning=ġ ̹ Դϴ. -WindowsVersionNotSupported= α׷ Windows ʽϴ. -WindowsServicePackRequired= α׷ Ϸ %1 sp%2 ̻̾ մϴ. -NotOnThisPlatform= α׷ %1 ۵ ʽϴ. -OnlyOnThisPlatform= α׷ %1 ؾ մϴ. -OnlyOnTheseArchitectures= α׷ Ʒ ó ȣȯǴ Windows ġ ֽϴ:%n%n%1 -WinVersionTooLowError= α׷ %1 %2 ̻ ʿմϴ. -WinVersionTooHighError= α׷ %1 %2 ̻󿡼 ġ ϴ. -AdminPrivilegesRequired= α׷ ġϷ ڷ αؾ մϴ. -PowerUserPrivilegesRequired= α׷ ġϷ Ǵ ڷ αؾ մϴ. -SetupAppRunningError= %1() Դϴ!%n%n װ νϽ ݾ ֽʽÿ. ׷ Ϸ "Ȯ", Ϸ "" ŬϽʽÿ. -UninstallAppRunningError= %1() Դϴ!%n%n װ νϽ ݾ ֽʽÿ. ׷ Ϸ "Ȯ", Ϸ "" ŬϽʽÿ. +LastErrorMessage=%1.%n%n오류 %2: %3 +SetupFileMissing=%1 파일이 존재하지 않습니다, 문제를 해결해 보거나 새로운 설치 프로그램을 구하시기 바랍니다. +SetupFileCorrupt=설치 파일이 손상되었습니다, 새로운 설치 프로그램을 구하시기 바랍니다. +SetupFileCorruptOrWrongVer=설치 파일의 손상이거나 이 설치 버전과 호환되지 않습니다, 문제를 해결해 보거나 새로운 설치 프로그램을 구하시기 바랍니다. +InvalidParameter=잘못된 매개 변수입니다:%n%n%1 +SetupAlreadyRunning=설치가 이미 실행 중입니다. +WindowsVersionNotSupported=이 프로그램은 귀하의 Windows 버전을 지원하지 않습니다. +WindowsServicePackRequired=이 프로그램을 실행하려면 %1 sp%2 이상이어야 합니다. +NotOnThisPlatform=이 프로그램은 %1에서 작동하지 않습니다. +OnlyOnThisPlatform=이 프로그램은 %1에서 실행해야 합니다. +OnlyOnTheseArchitectures=이 프로그램은 아래 처리 구조와 호환되는 Windows 버전에만 설치할 수 있습니다:%n%n%1 +WinVersionTooLowError=이 프로그램은 %1 버전 %2 이상이 필요합니다. +WinVersionTooHighError=이 프로그램은 %1 버전 %2 이상에서 설치할 수 없습니다. +AdminPrivilegesRequired=이 프로그램을 설치하려면 관리자로 로그인해야 합니다. +PowerUserPrivilegesRequired=이 프로그램을 설치하려면 관리자 또는 고급 사용자로 로그인해야 합니다. +SetupAppRunningError=현재 %1이(가) 실행 중입니다!%n%n지금 그것의 모든 인스턴스를 닫아 주십시오. 그런 다음 계속하려면 "확인"을, 종료하려면 "취소"를 클릭하십시오. +UninstallAppRunningError=현재 %1이(가) 실행 중입니다!%n%n지금 그것의 모든 인스턴스를 닫아 주십시오. 그런 다음 계속하려면 "확인"을, 종료하려면 "취소"를 클릭하십시오. ; *** Startup questions -PrivilegesRequiredOverrideTitle=ġ -PrivilegesRequiredOverrideInstruction=ġ 带 ֽʽÿ -PrivilegesRequiredOverrideText1=%1 ( ʿ) Ǵ ڿ ġմϴ. -PrivilegesRequiredOverrideText2=%1 Ǵ ( ʿ) ġմϴ. -PrivilegesRequiredOverrideAllUsers= ڿ ġ(&A) -PrivilegesRequiredOverrideAllUsersRecommended= ڿ ġ(&A) (õ) -PrivilegesRequiredOverrideCurrentUser= ڿ ġ(&M) -PrivilegesRequiredOverrideCurrentUserRecommended= ڿ ġ(&M) (õ) +PrivilegesRequiredOverrideTitle=설치 모드 선택 +PrivilegesRequiredOverrideInstruction=설치 모드를 선택해 주십시오 +PrivilegesRequiredOverrideText1=%1 은 모든 사용자(관리자 권한 필요) 또는 현재 사용자용으로 설치합니다. +PrivilegesRequiredOverrideText2=%1 은 현재 사용자 또는 모든 사용자(관리자 권한 필요) 용으로 설치합니다. +PrivilegesRequiredOverrideAllUsers=모든 사용자용으로 설치(&A) +PrivilegesRequiredOverrideAllUsersRecommended=모든 사용자용으로 설치(&A) (추천) +PrivilegesRequiredOverrideCurrentUser=현재 사용자용으로 설치(&M) +PrivilegesRequiredOverrideCurrentUserRecommended=현재 사용자용으로 설치(&M) (추천) ; *** Misc. errors -ErrorCreatingDir="%1" ϴ. -ErrorTooManyFilesInDir="%1" ʹ ϴ. +ErrorCreatingDir="%1" 폴더를 만들 수 없습니다. +ErrorTooManyFilesInDir="%1" 폴더에 파일이 너무 많기 때문에 파일을 만들 수 없습니다. ; *** Setup common messages -ExitSetupTitle=ġ Ϸ -ExitSetupMessage=ġ Ϸ ʾҽϴ, ⼭ ġ ϸ α׷ ġ ʽϴ.%n%nġ ϷϷ ߿ ٽ ġ α׷ ؾ մϴ.%n%n׷ ġ Ͻðڽϱ? -AboutSetupMenuItem=ġ (&A)... -AboutSetupTitle=ġ -AboutSetupMessage=%1 %2%n%3%n%n%1 Ȩ :%n%4 +ExitSetupTitle=설치 완료 +ExitSetupMessage=설치가 완료되지 않았습니다, 여기서 설치를 종료하면 프로그램은 설치되지 않습니다.%n%n설치를 완료하려면 나중에 다시 설치 프로그램을 실행해야 합니다.%n%n그래도 설치를 종료하시겠습니까? +AboutSetupMenuItem=설치 정보(&A)... +AboutSetupTitle=설치 정보 +AboutSetupMessage=%1 버전 %2%n%3%n%n%1 홈 페이지:%n%4 AboutSetupNote= TranslatorNote= ; *** Buttons -ButtonBack=< ڷ(&B) -ButtonNext=(&N) > -ButtonInstall=ġ(&I) -ButtonOK=Ȯ -ButtonCancel= -ButtonYes=(&Y) -ButtonYesToAll= (&A) -ButtonNo=ƴϿ(&N) -ButtonNoToAll= ƴϿ(&O) -ButtonFinish=(&F) -ButtonBrowse=ãƺ(&B)... -ButtonWizardBrowse=ãƺ(&R)... -ButtonNewFolder= (&M) +ButtonBack=< 뒤로(&B) +ButtonNext=다음(&N) > +ButtonInstall=설치(&I) +ButtonOK=확인 +ButtonCancel=취소 +ButtonYes=예(&Y) +ButtonYesToAll=모두 예(&A) +ButtonNo=아니오(&N) +ButtonNoToAll=모두 아니오(&O) +ButtonFinish=종료(&F) +ButtonBrowse=찾아보기(&B)... +ButtonWizardBrowse=찾아보기(&R)... +ButtonNewFolder=새 폴더 만들기(&M) ; *** "Select Language" dialog messages -SelectLanguageTitle=ġ -SelectLanguageLabel=ġ  Ͻʽÿ. +SelectLanguageTitle=설치 언어 선택 +SelectLanguageLabel=설치에 사용할 언어를 선택하십시오. ; *** Common wizard text -ClickNext=Ϸ "" Ŭϰ ġ Ϸ "" Ŭմϴ. +ClickNext=계속하려면 "다음"을 클릭하고 설치를 종료하려면 "취소"를 클릭합니다. BeveledLabel= -BrowseDialogTitle= ãƺ -BrowseDialogLabel=Ʒ Ͽ "Ȯ" Ŭմϴ. -NewFolderName= +BrowseDialogTitle=폴더 찾아보기 +BrowseDialogLabel=아래 목록에서 폴더를 선택한 다음 "확인"을 클릭합니다. +NewFolderName=새 폴더 ; *** "Welcome" wizard page -WelcomeLabel1=[name] ġ -WelcomeLabel2= ǻͿ [name/ver]() ġ Դϴ.%n%nġϱ ٸ α׷ ñ ٶϴ. +WelcomeLabel1=[name] 설치 마법사 시작 +WelcomeLabel2=이 마법사는 귀하의 컴퓨터에 [name/ver]을(를) 설치할 것입니다.%n%n설치하기 전에 다른 응용프로그램들을 모두 닫으시기 바랍니다. ; *** "Password" wizard page -WizardPassword= ȣ -PasswordLabel1= ġ ȣ ȣǾ ֽϴ. -PasswordLabel3= ȣ Էϰ "" ŬϽʽÿ. ȣ ҹڸ ؾ մϴ. -PasswordEditLabel= ȣ(&P): -IncorrectPassword= ȣ Ȯ ʽϴ, ٽ ԷϽʽÿ. +WizardPassword=비밀 번호 +PasswordLabel1=이 설치 마법사는 비밀 번호로 보호되어 있습니다. +PasswordLabel3=비밀 번호를 입력하고 "다음"을 클릭하십시오. 비밀 번호는 대소문자를 구분해야 합니다. +PasswordEditLabel=비밀 번호(&P): +IncorrectPassword=비밀 번호가 정확하지 않습니다, 다시 입력하십시오. ; *** "License Agreement" wizard page -WizardLicense= -LicenseLabel=ϱ ߿ оʽÿ. -LicenseLabel3= оʽÿ, ġ Ϸ ࿡ ؾ մϴ. -LicenseAccepted=մϴ(&A) -LicenseNotAccepted= ʽϴ(&D) +WizardLicense=사용권 계약 +LicenseLabel=계속하기 전에 다음의 중요 정보를 읽어보십시오. +LicenseLabel3=다음 사용권 계약을 읽어보십시오, 설치를 계속하려면 이 계약에 동의해야 합니다. +LicenseAccepted=동의합니다(&A) +LicenseNotAccepted=동의하지 않습니다(&D) ; *** "Information" wizard pages -WizardInfoBefore= -InfoBeforeLabel=ϱ ߿ оʽÿ. -InfoBeforeClickLabel=ġ Ϸ "" ŬϽʽÿ. -WizardInfoAfter= -InfoAfterLabel=ϱ ߿ оʽÿ. -InfoAfterClickLabel=ġ Ϸ "" ŬϽʽÿ. +WizardInfoBefore=정보 +InfoBeforeLabel=계속하기 전에 다음의 중요 정보를 읽어보십시오. +InfoBeforeClickLabel=설치를 계속하려면 "다음"을 클릭하십시오. +WizardInfoAfter=정보 +InfoAfterLabel=계속하기 전에 다음의 중요 정보를 읽어보십시오. +InfoAfterClickLabel=설치를 계속하려면 "다음"을 클릭하십시오. ; *** "User Information" wizard page -WizardUserInfo= -UserInfoDesc= ԷϽʽÿ. -UserInfoName= ̸(&U): -UserInfoOrg=(&O): -UserInfoSerial=ø ȣ(&S): -UserInfoNameRequired= ̸ ԷϽʽÿ. +WizardUserInfo=사용자 정보 +UserInfoDesc=사용자 정보를 입력하십시오. +UserInfoName=사용자 이름(&U): +UserInfoOrg=조직(&O): +UserInfoSerial=시리얼 번호(&S): +UserInfoNameRequired=사용자 이름을 입력하십시오. ; *** "Select Destination Location" wizard page -WizardSelectDir=ġ ġ -SelectDirDesc=[name] ġ ġ Ͻʽÿ. -SelectDirLabel3= [name]() ġմϴ. -SelectDirBrowseLabel=Ϸ "", ٸ Ϸ "ãƺ" ŬϽʽÿ. -DiskSpaceGBLabel= α׷ ּ [gb] GB ũ ʿմϴ. -DiskSpaceMBLabel= α׷ ּ [mb] MB ũ ʿմϴ. -CannotInstallToNetworkDrive=Ʈũ ̺꿡 ġ ϴ. -CannotInstallToUNCPath=UNC ο ġ ϴ. -InvalidPath=̺ ڸ ü θ ԷϽʽÿ.%n : C:\APP %n%nǴ, UNC θ ԷϽʽÿ.%n : \\server\share -InvalidDrive= ̺ Ǵ UNC ʰų ׼ ϴ, ٸ θ Ͻʽÿ. -DiskSpaceWarningTitle=ũ մϴ -DiskSpaceWarning=ġ ּ %1 KB ũ ʿ, ̺ %2 KB ۿ ϴ.%n%n׷ Ͻðڽϱ? -DirNameTooLong= ̸ Ǵ ΰ ʹ ϴ. -InvalidDirName= ̸ ȿ ʽϴ. -BadDirName32= ̸ ڸ ϴ:%n%n%1 -DirExistsTitle= մϴ -DirExists= %n%n%1%n%n() ̹ մϴ, ġϽðڽϱ? -DirDoesntExistTitle= ʽϴ -DirDoesntExist= %n%n%1%n%n() ʽϴ, ðڽϱ? +WizardSelectDir=설치 위치 선택 +SelectDirDesc=[name]의 설치 위치를 선택하십시오. +SelectDirLabel3=다음 폴더에 [name]을(를) 설치합니다. +SelectDirBrowseLabel=계속하려면 "다음"을, 다른 폴더를 선택하려면 "찾아보기"를 클릭하십시오. +DiskSpaceGBLabel=이 프로그램은 최소 [gb] GB의 디스크 여유 공간이 필요합니다. +DiskSpaceMBLabel=이 프로그램은 최소 [mb] MB의 디스크 여유 공간이 필요합니다. +CannotInstallToNetworkDrive=네트워크 드라이브에 설치할 수 없습니다. +CannotInstallToUNCPath=UNC 경로에 설치할 수 없습니다. +InvalidPath=드라이브 문자를 포함한 전체 경로를 입력하십시오.%n※ 예: C:\APP %n%n또는, UNC 형식의 경로를 입력하십시오.%n※ 예: \\server\share +InvalidDrive=선택한 드라이브 또는 UNC 공유가 존재하지 않거나 액세스할 수 없습니다, 다른 경로를 선택하십시오. +DiskSpaceWarningTitle=디스크 공간이 부족합니다 +DiskSpaceWarning=설치 시 최소 %1 KB 디스크 공간이 필요하지만, 선택한 드라이브의 여유 공간은 %2 KB 밖에 없습니다.%n%n그래도 계속하시겠습니까? +DirNameTooLong=폴더 이름 또는 경로가 너무 깁니다. +InvalidDirName=폴더 이름이 유효하지 않습니다. +BadDirName32=폴더 이름은 다음 문자를 포함할 수 없습니다:%n%n%1 +DirExistsTitle=폴더가 존재합니다 +DirExists=폴더 %n%n%1%n%n이(가) 이미 존재합니다, 이 폴더에 설치하시겠습니까? +DirDoesntExistTitle=폴더가 존재하지 않습니다 +DirDoesntExist=폴더 %n%n%1%n%n이(가) 존재하지 않습니다, 새로 폴더를 만드시겠습니까? ; *** "Select Components" wizard page -WizardSelectComponents= -SelectComponentsDesc=ġ Ҹ Ͻʽÿ. -SelectComponentsLabel2=ʿ Ҵ üũϰ ʿ Ҵ üũ մϴ, Ϸ "" ŬϽʽÿ. -FullInstallation= ġ +WizardSelectComponents=구성 요소 선택 +SelectComponentsDesc=설치할 구성 요소를 선택하십시오. +SelectComponentsLabel2=필요한 구성 요소는 체크하고 불필요한 구성 요소는 체크 해제합니다, 계속하려면 "다음"을 클릭하십시오. +FullInstallation=모두 설치 ; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language) -CompactInstallation=ּ ġ -CustomInstallation= ġ -NoUninstallWarningTitle= Ұ մϴ -NoUninstallWarning= Ұ ̹ ġǾ ֽϴ:%n%n%1%n%n , α׷ Ž ҵ ŵ ̴ϴ.%n%n׷ Ͻðڽϱ? +CompactInstallation=최소 설치 +CustomInstallation=사용자 지정 설치 +NoUninstallWarningTitle=구성 요소가 존재합니다 +NoUninstallWarning=다음 구성 요소가 이미 설치되어 있습니다:%n%n%1%n%n위 구성 요소을 선택하지 않으면, 프로그램 제거시 이 구성 요소들은 제거되지 않을 겁니다.%n%n그래도 계속하시겠습니까? ComponentSize1=%1 KB ComponentSize2=%1 MB -ComponentsDiskSpaceGBLabel= ּ [gb] GB ũ ʿմϴ. -ComponentsDiskSpaceMBLabel= ּ [mb] MB ũ ʿմϴ. +ComponentsDiskSpaceGBLabel=현재 선택은 최소 [gb] GB의 디스크 여유 공간이 필요합니다. +ComponentsDiskSpaceMBLabel=현재 선택은 최소 [mb] MB의 디스크 여유 공간이 필요합니다. ; *** "Select Additional Tasks" wizard page -WizardSelectTasks=߰ ۾ -SelectTasksDesc= ߰ ۾ Ͻʽÿ. -SelectTasksLabel2=[name] ġ ߰ ۾ , "" ŬϽʽÿ. +WizardSelectTasks=추가 작업 선택 +SelectTasksDesc=수행할 추가 작업을 선택하십시오. +SelectTasksLabel2=[name] 설치 과정에 포함할 추가 작업을 선택한 후, "다음"을 클릭하십시오. ; *** "Select Start Menu Folder" wizard page -WizardSelectProgramGroup= ޴ -SelectStartMenuFolderDesc= α׷ ٷΰ⸦ ġϰڽϱ? -SelectStartMenuFolderLabel3= ޴ α׷ ٷΰ⸦ ϴ. -SelectStartMenuFolderBrowseLabel=Ϸ "" Ŭϰ, ٸ Ϸ "ãƺ" ŬϽʽÿ. -MustEnterGroupName= ̸ ԷϽʽÿ. -GroupNameTooLong= ̸ Ǵ ΰ ʹ ϴ. -InvalidGroupName= ̸ ȿ ʽϴ. -BadGroupName= ̸ ڸ ϴ:%n%n%1 -NoProgramGroupCheck2= ޴ (&D) +WizardSelectProgramGroup=시작 메뉴 폴더 선택 +SelectStartMenuFolderDesc=어디에 프로그램 바로가기를 위치하겠습니까? +SelectStartMenuFolderLabel3=다음 시작 메뉴 폴더에 프로그램 바로가기를 만듭니다. +SelectStartMenuFolderBrowseLabel=계속하려면 "다음"을 클릭하고, 다른 폴더를 선택하려면 "찾아보기"를 클릭하십시오. +MustEnterGroupName=폴더 이름을 입력하십시오. +GroupNameTooLong=폴더 이름 또는 경로가 너무 깁니다. +InvalidGroupName=폴더 이름이 유효하지 않습니다. +BadGroupName=폴더 이름은 다음 문자를 포함할 수 없습니다:%n%n%1 +NoProgramGroupCheck2=시작 메뉴 폴더를 만들지 않음(&D) ; *** "Ready to Install" wizard page -WizardReady=ġ غ Ϸ -ReadyLabel1= ǻͿ [name]() ġ غ Ǿϴ. -ReadyLabel2a=ġ Ϸ "ġ", ϰų Ϸ "ڷ" ŬϽʽÿ. -ReadyLabel2b=ġ Ϸ "ġ" ŬϽʽÿ. -ReadyMemoUserInfo= : -ReadyMemoDir=ġ ġ: -ReadyMemoType=ġ : -ReadyMemoComponents= : -ReadyMemoGroup= ޴ : -ReadyMemoTasks=߰ ۾: +WizardReady=설치 준비 완료 +ReadyLabel1=귀하의 컴퓨터에 [name]을(를) 설치할 준비가 되었습니다. +ReadyLabel2a=설치를 계속하려면 "설치"를, 설정을 변경하거나 검토하려면 "뒤로"를 클릭하십시오. +ReadyLabel2b=설치를 계속하려면 "설치"를 클릭하십시오. +ReadyMemoUserInfo=사용자 정보: +ReadyMemoDir=설치 위치: +ReadyMemoType=설치 유형: +ReadyMemoComponents=선택한 구성 요소: +ReadyMemoGroup=시작 메뉴 폴더: +ReadyMemoTasks=추가 작업: ; *** "Preparing to Install" wizard page -WizardPreparing=ġ غ -PreparingDesc= ǻͿ [name] ġ غϴ Դϴ. -PreviousInstallNotCompleted= α׷ ġ/ ۾ Ϸ ʾҽϴ, ϷϷ ǻ͸ ٽ ؾ մϴ.%n%nǻ͸ ٽ , ġ 縦 ٽ Ͽ [name] ġ ϷϽñ ٶϴ. -CannotContinue=ġ ϴ, "" ŬϿ ġ Ͻʽÿ. -ApplicationsFound= α׷ ġ Ʈ ʿ ϰ ֽϴ, ġ 簡 ̷ α׷ ڵ ֵ Ͻñ ٶϴ. -ApplicationsFound2= α׷ ġ Ʈ ʿ ϰ ֽϴ, ġ 簡 ̷ α׷ ڵ ֵ Ͻñ ٶϴ. ġ ϷǸ, ġ α׷ ٽ ۵ǵ õ ̴ϴ. -CloseApplications=ڵ α׷ (&A) -DontCloseApplications=α׷ (&D) -ErrorCloseApplications=ġ 簡 α׷ ڵ ϴ, ϱ ġ Ʈ ʿ ϰ ִ α׷ Ͻñ ٶϴ. -PrepareToInstallNeedsRestart=ġ ǻ͸ ؾ մϴ. [name] ġ Ϸϱ ǻ͸ ٽ Ŀ ġ 縦 ٽ ֽʽÿ.%n%n ٽ Ͻðڽϱ? +WizardPreparing=설치 준비 중 +PreparingDesc=귀하의 컴퓨터에 [name] 설치를 준비하는 중입니다. +PreviousInstallNotCompleted=이전 프로그램의 설치/제거 작업이 완료되지 않았습니다, 완료하려면 컴퓨터를 다시 시작해야 합니다.%n%n컴퓨터를 다시 시작한 후, 설치 마법사를 다시 실행하여 [name] 설치를 완료하시기 바랍니다. +CannotContinue=설치를 계속할 수 없습니다, "취소"를 클릭하여 설치를 종료하십시오. +ApplicationsFound=다음 응용프로그램이 설치 업데이트가 필요한 파일을 사용하고 있습니다, 설치 마법사가 이런 응용프로그램을 자동으로 종료할 수 있도록 허용하시기 바랍니다. +ApplicationsFound2=다음 응용프로그램이 설치 업데이트가 필요한 파일을 사용하고 있습니다, 설치 마법사가 이런 응용프로그램을 자동으로 종료할 수 있도록 허용하시기 바랍니다. 설치가 완료되면, 설치 마법사는 이 응용프로그램이 다시 시작되도록 시도할 겁니다. +CloseApplications=자동으로 응용프로그램을 종료함(&A) +DontCloseApplications=응용프로그램을 종료하지 않음(&D) +ErrorCloseApplications=설치 마법사가 응용프로그램을 자동으로 종료할 수 없습니다, 계속하기 전에 설치 업데이트가 필요한 파일을 사용하고 있는 응용프로그램을 모두 종료하시기 바랍니다. +PrepareToInstallNeedsRestart=설치 마법사는 귀하의 컴퓨터를 재시작해야 합니다. [name] 설치를 완료하기 위해 컴퓨터를 다시 시작한 후에 설치 마법사를 다시 실행해 주십시오.%n%n지금 다시 시작하시겠습니까? ; *** "Installing" wizard page -WizardInstalling=ġ -InstallingLabel= ǻͿ [name]() ġϴ ... ٷ ֽʽÿ. +WizardInstalling=설치 중 +InstallingLabel=귀하의 컴퓨터에 [name]을(를) 설치하는 중... 잠시 기다려 주십시오. ; *** "Setup Completed" wizard page -FinishedHeadingLabel=[name] ġ Ϸ -FinishedLabelNoIcons= ǻͿ [name]() ġǾϴ. -FinishedLabel= ǻͿ [name]() ġǾϴ, α׷ ġ Ͽ ֽϴ. -ClickFinish=ġ "" ŬϽʽÿ. -FinishedRestartLabel=[name] ġ ϷϷ, ǻ͸ ٽ ؾ մϴ. ٽ Ͻðڽϱ? -FinishedRestartMessage=[name] ġ ϷϷ, ǻ͸ ٽ ؾ մϴ.%n%n ٽ Ͻðڽϱ? -ShowReadmeCheck=, README ǥմϴ -YesRadio=, ٽ մϴ(&Y) -NoRadio=ƴϿ, ߿ ٽ մϴ(&N) +FinishedHeadingLabel=[name] 설치 마법사 완료 +FinishedLabelNoIcons=귀하의 컴퓨터에 [name]이(가) 설치되었습니다. +FinishedLabel=귀하의 컴퓨터에 [name]이(가) 설치되었습니다, 응용프로그램은 설치된 아이콘을 선택하여 시작할 수 있습니다. +ClickFinish=설치를 끝내려면 "종료"를 클릭하십시오. +FinishedRestartLabel=[name] 설치를 완료하려면, 컴퓨터를 다시 시작해야 합니다. 지금 다시 시작하시겠습니까? +FinishedRestartMessage=[name] 설치를 완료하려면, 컴퓨터를 다시 시작해야 합니다.%n%n지금 다시 시작하시겠습니까? +ShowReadmeCheck=예, README 파일을 표시합니다 +YesRadio=예, 지금 다시 시작합니다(&Y) +NoRadio=아니오, 나중에 다시 시작합니다(&N) ; used for example as 'Run MyProg.exe' -RunEntryExec=%1 +RunEntryExec=%1 실행 ; used for example as 'View Readme.txt' -RunEntryShellExec=%1 ǥ +RunEntryShellExec=%1 표시 ; *** "Setup Needs the Next Disk" stuff -ChangeDiskTitle=ũ ʿմϴ -SelectDiskLabel2=ũ %1() ϰ "Ȯ" ŬϽʽÿ.%n%n ũ Ʒ ΰ ƴ ִ , ùٸ θ Էϰų "ãƺ" ŬϽñ ٶϴ. -PathLabel=(&P): -FileNotInDir2=%2 %1() ġ ϴ, ùٸ ũ ϰų ٸ Ͻʽÿ. -SelectDirectoryLabel= ũ ġ Ͻʽÿ. +ChangeDiskTitle=디스크가 필요합니다 +SelectDiskLabel2=디스크 %1을(를) 삽입하고 "확인"을 클릭하십시오.%n%n이 디스크 상의 파일이 아래 경로가 아닌 곳에 있는 경우, 올바른 경로를 입력하거나 "찾아보기"를 클릭하시기 바랍니다. +PathLabel=경로(&P): +FileNotInDir2=%2에 파일 %1을(를) 위치할 수 없습니다, 올바른 디스크를 삽입하거나 다른 폴더를 선택하십시오. +SelectDirectoryLabel=다음 디스크의 위치를 지정하십시오. ; *** Installation phase messages -SetupAborted=ġ Ϸ ʾҽϴ.%n%n ذ , ٽ ġ Ͻʽÿ. -AbortRetryIgnoreSelectAction=׼ ֽʽÿ. -AbortRetryIgnoreRetry=õ(&T) -AbortRetryIgnoreIgnore= ϰ (&I) -AbortRetryIgnoreCancel=ġ +SetupAborted=설치가 완료되지 않았습니다.%n%n문제를 해결한 후, 다시 설치를 시작하십시오. +AbortRetryIgnoreSelectAction=액션을 선택해 주십시오. +AbortRetryIgnoreRetry=재시도(&T) +AbortRetryIgnoreIgnore=오류를 무시하고 진행(&I) +AbortRetryIgnoreCancel=설치 취소 ; *** Installation status messages -StatusClosingApplications=α׷ ϴ ... -StatusCreateDirs= ... -StatusExtractFiles= ϴ ... -StatusCreateIcons=ٷΰ⸦ ϴ ... -StatusCreateIniEntries=INI ׸ ... -StatusCreateRegistryEntries=Ʈ ׸ ... -StatusRegisterFiles= ϴ ... -StatusSavingUninstall= ϴ ... -StatusRunProgram=ġ Ϸϴ ... -StatusRestartingApplications=α׷ ٽ ϴ ... -StatusRollback= ϴ ... +StatusClosingApplications=응용프로그램을 종료하는 중... +StatusCreateDirs=폴더를 만드는 중... +StatusExtractFiles=파일을 추출하는 중... +StatusCreateIcons=바로가기를 생성하는 중... +StatusCreateIniEntries=INI 항목을 만드는 중... +StatusCreateRegistryEntries=레지스트리 항목을 만드는 중... +StatusRegisterFiles=파일을 등록하는 중... +StatusSavingUninstall=제거 정보를 저장하는 중... +StatusRunProgram=설치를 완료하는 중... +StatusRestartingApplications=응용프로그램을 다시 시작하는 중... +StatusRollback=변경을 취소하는 중... ; *** Misc. errors -ErrorInternal2= : %1 -ErrorFunctionFailedNoCode=%1 -ErrorFunctionFailed=%1 ; ڵ %2 -ErrorFunctionFailedWithMessage=%1 , ڵ: %2.%n%3 -ErrorExecutingProgram= :%n%1 +ErrorInternal2=내부 오류: %1 +ErrorFunctionFailedNoCode=%1 실패 +ErrorFunctionFailed=%1 실패; 코드 %2 +ErrorFunctionFailedWithMessage=%1 실패, 코드: %2.%n%3 +ErrorExecutingProgram=파일 실행 오류:%n%1 ; *** Registry errors -ErrorRegOpenKey=Ʈ Ű :%n%1\%2 -ErrorRegCreateKey=Ʈ Ű :%n%1\%2 -ErrorRegWriteKey=Ʈ Ű :%n%1\%2 +ErrorRegOpenKey=레지스트리 키 열기 오류:%n%1\%2 +ErrorRegCreateKey=레지스트리 키 생성 오류:%n%1\%2 +ErrorRegWriteKey=레지스트리 키 쓰기 오류:%n%1\%2 ; *** INI errors -ErrorIniEntry=%1 Ͽ INI ׸ Դϴ. +ErrorIniEntry=%1 파일에 INI 항목 만들기 오류입니다. ; *** File copying errors -FileAbortRetryIgnoreSkipNotRecommended= dzʶ(&S) ( ʽϴ) -FileAbortRetryIgnoreIgnoreNotRecommended= ϰ (&I) ( ʽϴ) -SourceIsCorrupted= ջ -SourceDoesntExist= %1() -ExistingFileReadOnly2= б ̱⶧ ü ϴ. -ExistingFileReadOnlyRetry=б Ӽ ϰ ٽ õϷ(&R) -ExistingFileReadOnlyKeepExisting= (&K) -ErrorReadingExistingDest= д ߻: -FileExists= ̹ մϴ.%n%n ðڽϱ? -ExistingFileNewer= ġϷ ϴ Ϻ Դϴ, Ͻñ ٶϴ.%n%n Ͻðڽϱ? -ErrorChangingAttr= Ӽ ϴ ߻: -ErrorCreatingTemp= ߻: -ErrorReadingSource= д ߻: -ErrorCopying= ϴ ߻: -ErrorReplacingExistingFile= üϴ ߻: -ErrorRestartReplace=RestartReplace : -ErrorRenamingTemp= ̸ ٲٴ ߻: -ErrorRegisterServer=DLL/OCX : %1 -ErrorRegSvr32Failed=RegSvr32 ڵ : %1 -ErrorRegisterTypeLib= ̺귯 Ͽ : %1 +FileAbortRetryIgnoreSkipNotRecommended=이 파일을 건너띔(&S) (권장하지 않습니다) +FileAbortRetryIgnoreIgnoreNotRecommended=오류를 무시하고 진행(&I) (권장하지 않습니다) +SourceIsCorrupted=원본 파일이 손상됨 +SourceDoesntExist=원본 파일 %1이(가) 존재하지 않음 +ExistingFileReadOnly2=기존 파일은 읽기 전용이기때문에 대체할 수 없습니다. +ExistingFileReadOnlyRetry=읽기 전용 속성을 해제하고 다시 시도하려면(&R) +ExistingFileReadOnlyKeepExisting=기존 파일을 유지(&K) +ErrorReadingExistingDest=기존 파일을 읽는 동안 오류 발생: +FileExists=파일이 이미 존재합니다.%n%n파일을 덮어쓰시겠습니까? +ExistingFileNewer=기존 파일이 설치하려고 하는 파일보다 새 파일입니다, 기존 파일을 유지하시기 바랍니다.%n%n기존 파일을 유지하시겠습니까? +ErrorChangingAttr=기존 파일의 속성을 변경하는 동안 오류 발생: +ErrorCreatingTemp=대상 폴더에 파일을 만드는 동안 오류 발생: +ErrorReadingSource=원본 파일을 읽는 동안 오류 발생: +ErrorCopying=파일을 복사하는 동안 오류 발생: +ErrorReplacingExistingFile=기존 파일을 교체하는 동안 오류 발생: +ErrorRestartReplace=RestartReplace 실패: +ErrorRenamingTemp=대상 폴더 내의 파일 이름을 바꾸는 동안 오류 발생: +ErrorRegisterServer=DLL/OCX 등록 실패: %1 +ErrorRegSvr32Failed=RegSvr32가 다음 종료 코드로 실패: %1 +ErrorRegisterTypeLib=다음 유형의 라이브러리 등록에 실패: %1 ; *** Uninstall display name markings ; used for example as 'My Program (32-bit)' UninstallDisplayNameMark=%1 (%2) ; used for example as 'My Program (32-bit, All users)' UninstallDisplayNameMarks=%1 (%2, %3) -UninstallDisplayNameMark32Bit=32Ʈ -UninstallDisplayNameMark64Bit=64Ʈ -UninstallDisplayNameMarkAllUsers= -UninstallDisplayNameMarkCurrentUser= +UninstallDisplayNameMark32Bit=32비트 +UninstallDisplayNameMark64Bit=64비트 +UninstallDisplayNameMarkAllUsers=모든 사용자 +UninstallDisplayNameMarkCurrentUser=현재 사용자 ; *** Post-installation errors -ErrorOpeningReadme=README ߻߽ϴ. -ErrorRestartingComputer=ǻ͸ ٽ ϴ, ٽ Ͻʽÿ. +ErrorOpeningReadme=README 파일을 여는 중 오류가 발생했습니다. +ErrorRestartingComputer=컴퓨터를 다시 시작할 수 없습니다, 수동으로 다시 시작하십시오. ; *** Uninstaller messages -UninstallNotFound= %1() ʱ , Ÿ ϴ. -UninstallOpenError= %1() , Ÿ ϴ. -UninstallUnsupportedVer= α "%1"() ν ̱ , Ÿ ϴ. -UninstallUnknownEntry= ׸ %1() α׿ ԵǾ ֽϴ. -ConfirmUninstall= %1() Ҹ Ͻðڽϱ? -UninstallOnlyOnWin64= α׷ 64Ʈ Windows ֽϴ. -OnlyAdminCanUninstall= α׷ Ϸ ʿմϴ. -UninstallStatusLabel= ǻͿ %1() ϴ ... ٷ ֽʽÿ. -UninstalledAll=%1() ŵǾϴ! -UninstalledMost=%1 Ű ϷǾϴ.%n%nϺ Ҵ , Ͻñ ٶϴ. -UninstalledAndNeedsRestart=%1 Ÿ ϷϷ, ǻ͸ ٽ ؾ մϴ.%n%n ٽ Ͻðڽϱ? -UninstallDataCorrupted= "%1"() ջǾ , Ÿ ϴ. +UninstallNotFound=파일 %1이(가) 존재하지 않기 때문에, 제거를 실행할 수 없습니다. +UninstallOpenError=파일 %1을(를) 열 수 없기 때문에, 제거를 실행할 수 없습니다. +UninstallUnsupportedVer=삭제 로그 파일 "%1"은(는) 이 삭제 마법사로 인식할 수 없는 형식이기 때문에, 제거를 실행할 수 없습니다. +UninstallUnknownEntry=알 수 없는 항목 %1이(가) 삭제 로그에 포함되어 있습니다. +ConfirmUninstall=정말 %1와(과) 그 구성 요소를 모두 제거하시겠습니까? +UninstallOnlyOnWin64=이 프로그램은 64비트 Windows에서만 제거할 수 있습니다. +OnlyAdminCanUninstall=이 프로그램을 제거하려면 관리자 권한이 필요합니다. +UninstallStatusLabel=귀하의 컴퓨터에서 %1을(를) 제거하는 중... 잠시 기다려 주십시오. +UninstalledAll=%1이(가) 성공적으로 제거되었습니다! +UninstalledMost=%1 제거가 완료되었습니다.%n%n일부 요소는 삭제할 수 없으니, 수동으로 제거하시기 바랍니다. +UninstalledAndNeedsRestart=%1의 제거를 완료하려면, 컴퓨터를 다시 시작해야 합니다.%n%n지금 다시 시작하시겠습니까? +UninstallDataCorrupted=파일 "%1"이(가) 손상되었기 때문에, 제거를 실행할 수 없습니다. ; *** Uninstallation phase messages -ConfirmDeleteSharedFileTitle= Ͻðڽϱ? -ConfirmDeleteSharedFile2=ý  α׷ ʽϴ, Ͻðڽϱ?%n%n ٸ α׷ ϰ ִ ¿ , ش α׷ ۵ , Ȯ "ƴϿ" ϼŵ ˴ϴ. ýۿ ־ ʽϴ. -SharedFileNameLabel= ̸: -SharedFileLocationLabel=ġ: -WizardUninstalling= -StatusUninstalling=%1() ϴ ... +ConfirmDeleteSharedFileTitle=공유 파일을 제거하시겠습니까? +ConfirmDeleteSharedFile2=시스템의 어떤 프로그램도 다음 공유 파일을 사용하지 않습니다, 이 공유 파일을 삭제하시겠습니까?%n%n이 파일을 다른 프로그램이 공유하고 있는 상태에서 이 파일을 제거할 경우, 해당 프로그램이 제대로 작동하지 않을 수 있으니, 확신이 없으면 "아니오"를 선택하셔도 됩니다. 시스템에 파일이 남아 있어도 문제가 되진 않습니다. +SharedFileNameLabel=파일 이름: +SharedFileLocationLabel=위치: +WizardUninstalling=제거 상태 +StatusUninstalling=%1을(를) 제거하는 중... ; *** Shutdown block reasons -ShutdownBlockReasonInstallingApp=%1() ġϴ Դϴ. -ShutdownBlockReasonUninstallingApp=%1() ϴ Դϴ. +ShutdownBlockReasonInstallingApp=%1을(를) 설치하는 중입니다. +ShutdownBlockReasonUninstallingApp=%1을(를) 제거하는 중입니다. ; The custom messages below aren't used by Setup itself, but if you make ; use of them in your scripts, you'll want to translate them. [CustomMessages] -NameAndVersion=%1 %2 -AdditionalIcons= ߰: -CreateDesktopIcon= ȭ鿡 ٷΰ (&D) -CreateQuickLaunchIcon= (&Q) -ProgramOnTheWeb=%1 -UninstallProgram=%1 -LaunchProgram=%1 -AssocFileExtension= Ȯ %2() %1 մϴ. -AssocingFileExtension= Ȯ %2() %1 ϴ ... -AutoStartProgramGroupDescription=: -AutoStartProgram=%1() ڵ -AddonHostProgramNotFound=%1() ġ ϴ.%n%n׷ Ͻðڽϱ? +NameAndVersion=%1 버전 %2 +AdditionalIcons=아이콘 추가: +CreateDesktopIcon=바탕 화면에 바로가기 만들기(&D) +CreateQuickLaunchIcon=빠른 실행 아이콘 만들기(&Q) +ProgramOnTheWeb=%1 웹페이지 +UninstallProgram=%1 제거 +LaunchProgram=%1 실행 +AssocFileExtension=파일 확장자 %2을(를) %1에 연결합니다. +AssocingFileExtension=파일 확장자 %2을(를) %1에 연결하는 중... +AutoStartProgramGroupDescription=시작: +AutoStartProgram=%1을(를) 자동으로 시작 +AddonHostProgramNotFound=%1은(는) 선택한 폴더에 위치할 수 없습니다.%n%n그래도 계속하시겠습니까? diff --git a/build/win32/i18n/Default.zh-cn.isl b/build/win32/i18n/Default.zh-cn.isl index 5c5df9a166f0d..96d89f074e3c8 100644 --- a/build/win32/i18n/Default.zh-cn.isl +++ b/build/win32/i18n/Default.zh-cn.isl @@ -13,7 +13,7 @@ LanguageName=简体中文 ; If Language Name display incorrect, uncomment next line ; LanguageName=<7B80><4F53><4E2D><6587> LanguageID=$0804 -LanguageCodePage=936 +LanguageCodePage=0 ; If the language you are translating to requires special font faces or ; sizes, uncomment any of the following entries and change them accordingly. ;DialogFontName= diff --git a/build/win32/i18n/Default.zh-tw.isl b/build/win32/i18n/Default.zh-tw.isl index fb748761f99e2..60d7e46e77a84 100644 --- a/build/win32/i18n/Default.zh-tw.isl +++ b/build/win32/i18n/Default.zh-tw.isl @@ -11,7 +11,7 @@ ; If Language Name display incorrect, uncomment next line LanguageName=<7e41><9ad4><4e2d><6587> LanguageID=$0404 -LanguageCodepage=950 +LanguageCodePage=0 ; If the language you are translating to requires special font faces or ; sizes, uncomment any of the following entries and change them accordingly. DialogFontName=新細明體 diff --git a/build/win32/i18n/messages.de.isl b/build/win32/i18n/messages.de.isl index 8d065e6c10ac1..3a3c7fa67c38a 100644 --- a/build/win32/i18n/messages.de.isl +++ b/build/win32/i18n/messages.de.isl @@ -1,10 +1,10 @@ -[CustomMessages] -AddContextMenuFiles=Aktion "Mit %1 ffnen" dem Dateikontextmen von Windows-Explorer hinzufgen -AddContextMenuFolders=Aktion "Mit %1 ffnen" dem Verzeichniskontextmen von Windows-Explorer hinzufgen -AssociateWithFiles=%1 als Editor fr untersttzte Dateitypen registrieren -AddToPath=Zu PATH hinzufgen (nach dem Neustart verfgbar) -RunAfter=%1 nach der Installation ausfhren +[CustomMessages] +AddContextMenuFiles=Aktion "Mit %1 öffnen" dem Dateikontextmenü von Windows-Explorer hinzufügen +AddContextMenuFolders=Aktion "Mit %1 öffnen" dem Verzeichniskontextmenü von Windows-Explorer hinzufügen +AssociateWithFiles=%1 als Editor für unterstützte Dateitypen registrieren +AddToPath=Zu PATH hinzufügen (nach dem Neustart verfügbar) +RunAfter=%1 nach der Installation ausführen Other=Andere: SourceFile=%1-Quelldatei -OpenWithCodeContextMenu=Mit %1 ffnen +OpenWithCodeContextMenu=Mit %1 öffnen UpdatingVisualStudioCode=Visual Studio Code wird aktualisiert... \ No newline at end of file diff --git a/build/win32/i18n/messages.en.isl b/build/win32/i18n/messages.en.isl index a5cc58201543e..6255123d34d1a 100644 --- a/build/win32/i18n/messages.en.isl +++ b/build/win32/i18n/messages.en.isl @@ -1,4 +1,4 @@ -[Messages] +[Messages] FinishedLabel=Setup has finished installing [name] on your computer. The application may be launched by selecting the installed shortcuts. ConfirmUninstall=Are you sure you want to completely remove %1 and all of its components? diff --git a/build/win32/i18n/messages.es.isl b/build/win32/i18n/messages.es.isl index 66b7534a20770..7f6f1bd06c581 100644 --- a/build/win32/i18n/messages.es.isl +++ b/build/win32/i18n/messages.es.isl @@ -1,9 +1,9 @@ -[CustomMessages] -AddContextMenuFiles=Agregar la accin "Abrir con %1" al men contextual de archivo del Explorador de Windows -AddContextMenuFolders=Agregar la accin "Abrir con %1" al men contextual de directorio del Explorador de Windows +[CustomMessages] +AddContextMenuFiles=Agregar la acción "Abrir con %1" al menú contextual de archivo del Explorador de Windows +AddContextMenuFolders=Agregar la acción "Abrir con %1" al menú contextual de directorio del Explorador de Windows AssociateWithFiles=Registrar %1 como editor para tipos de archivo admitidos -AddToPath=Agregar a PATH (disponible despus de reiniciar) -RunAfter=Ejecutar %1 despus de la instalacin +AddToPath=Agregar a PATH (disponible después de reiniciar) +RunAfter=Ejecutar %1 después de la instalación Other=Otros: SourceFile=Archivo de origen %1 OpenWithCodeContextMenu=Abrir &con %1 diff --git a/build/win32/i18n/messages.fr.isl b/build/win32/i18n/messages.fr.isl index 348d6be00495f..65ad8f194fbb4 100644 --- a/build/win32/i18n/messages.fr.isl +++ b/build/win32/i18n/messages.fr.isl @@ -1,10 +1,10 @@ -[CustomMessages] +[CustomMessages] AddContextMenuFiles=Ajouter l'action "Ouvrir avec %1" au menu contextuel de fichier de l'Explorateur Windows -AddContextMenuFolders=Ajouter l'action "Ouvrir avec %1" au menu contextuel de rpertoire de l'Explorateur Windows -AssociateWithFiles=Inscrire %1 en tant qu'diteur pour les types de fichier pris en charge -AddToPath=Ajouter PATH (disponible aprs le redmarrage) -RunAfter=Excuter %1 aprs l'installation -Other=Autre: +AddContextMenuFolders=Ajouter l'action "Ouvrir avec %1" au menu contextuel de répertoire de l'Explorateur Windows +AssociateWithFiles=Inscrire %1 en tant qu'éditeur pour les types de fichier pris en charge +AddToPath=Ajouter à PATH (disponible après le redémarrage) +RunAfter=Exécuter %1 après l'installation +Other=Autre : SourceFile=Fichier source %1 OpenWithCodeContextMenu=Ouvrir avec %1 -UpdatingVisualStudioCode=Mise jour de Visual Studio Code... \ No newline at end of file +UpdatingVisualStudioCode=Mise à jour de Visual Studio Code... \ No newline at end of file diff --git a/build/win32/i18n/messages.hu.isl b/build/win32/i18n/messages.hu.isl index ef3862ad35b10..87487f60214e7 100644 --- a/build/win32/i18n/messages.hu.isl +++ b/build/win32/i18n/messages.hu.isl @@ -1,10 +1,10 @@ -[CustomMessages] -AddContextMenuFiles="Megnyits a kvetkezvel: %1" parancs hozzadsa a fjlok helyi menjhez a Windows Intzben -AddContextMenuFolders="Megnyits a kvetkezvel: %1" parancs hozzadsa a mappk helyi menjhez a Windows Intzben -AssociateWithFiles=%1 regisztrlsa szerkesztknt a tmogatott fjltpusokhoz -AddToPath=Hozzads a PATH-hoz (jraindts utn lesz elrhet) -RunAfter=%1 indtsa a telepts utn -Other=Egyb: -SourceFile=%1 forrsfjl -OpenWithCodeContextMenu=Megnyits a kvetkezvel: %1 -UpdatingVisualStudioCode=A Visual Studio Code frisstse... \ No newline at end of file +[CustomMessages] +AddContextMenuFiles="Megnyitás a következővel: %1" parancs hozzáadása a fájlok helyi menüjéhez a Windows Intézőben +AddContextMenuFolders="Megnyitás a következővel: %1" parancs hozzáadása a mappák helyi menüjéhez a Windows Intézőben +AssociateWithFiles=%1 regisztrálása szerkesztőként a támogatott fájltípusokhoz +AddToPath=Hozzáadás a PATH-hoz (újraindítás után lesz elérhető) +RunAfter=%1 indítása a telepítés után +Other=Egyéb: +SourceFile=%1 forrásfájl +OpenWithCodeContextMenu=Megnyitás a következővel: %1 +UpdatingVisualStudioCode=A Visual Studio Code frissítése... \ No newline at end of file diff --git a/build/win32/i18n/messages.it.isl b/build/win32/i18n/messages.it.isl index bc23825844a3b..ac64aae78b984 100644 --- a/build/win32/i18n/messages.it.isl +++ b/build/win32/i18n/messages.it.isl @@ -1,4 +1,4 @@ -[CustomMessages] +[CustomMessages] AddContextMenuFiles=Aggiungi azione "Apri con %1" al menu di scelta rapida file di Esplora risorse AddContextMenuFolders=Aggiungi azione "Apri con %1" al menu di scelta rapida directory di Esplora risorse AssociateWithFiles=Registra %1 come editor per i tipi di file supportati diff --git a/build/win32/i18n/messages.ja.isl b/build/win32/i18n/messages.ja.isl index ef10366b46993..76e634fde0743 100644 --- a/build/win32/i18n/messages.ja.isl +++ b/build/win32/i18n/messages.ja.isl @@ -1,10 +1,10 @@ -[CustomMessages] -AddContextMenuFiles=GNXv[[̃t@C ReLXg j[ [%1 ŊJ] ANVlj -AddContextMenuFolders=GNXv[[̃fBNg ReLXg j[ [%1 ŊJ] ANVlj -AssociateWithFiles=T|[gĂt@C̎ނ̃GfB^[ƂāA%1 o^ -AddToPath=PATH ւ̒ljiċNɎgp”\j -RunAfter=CXg[ %1 s -Other=̑: -SourceFile=%1 \[X t@C -OpenWithCodeContextMenu=%1 ŊJ -UpdatingVisualStudioCode=Visual Studio Code XVĂ܂... \ No newline at end of file +[CustomMessages] +AddContextMenuFiles=エクスプローラーのファイル コンテキスト メニューに [%1 で開く] アクションを追加する +AddContextMenuFolders=エクスプローラーのディレクトリ コンテキスト メニューに [%1 で開く] アクションを追加する +AssociateWithFiles=サポートされているファイルの種類のエディターとして、%1 を登録する +AddToPath=PATH への追加(再起動後に使用可能) +RunAfter=インストール後に %1 を実行する +Other=その他: +SourceFile=%1 ソース ファイル +OpenWithCodeContextMenu=%1 で開く +UpdatingVisualStudioCode=Visual Studio Code を更新しています... \ No newline at end of file diff --git a/build/win32/i18n/messages.ko.isl b/build/win32/i18n/messages.ko.isl index f938c75e289b1..40abfafad06ee 100644 --- a/build/win32/i18n/messages.ko.isl +++ b/build/win32/i18n/messages.ko.isl @@ -1,10 +1,10 @@ -[CustomMessages] -AddContextMenuFiles="%1() " ۾ Windows Ž Ȳ ´ ޴ ߰ -AddContextMenuFolders="%1() " ۾ Windows Ž ͸ Ȳ ´ ޴ ߰ -AssociateWithFiles=%1() Ǵ Ŀ մϴ. -AddToPath=PATH ߰(ٽ ) -RunAfter=ġ %1 -Other=Ÿ: -SourceFile=%1 -OpenWithCodeContextMenu=%1() -UpdatingVisualStudioCode=Visual Studio Code Ʈ ... \ No newline at end of file +[CustomMessages] +AddContextMenuFiles="%1(으)로 열기" 작업을 Windows 탐색기 파일의 상황에 맞는 메뉴에 추가 +AddContextMenuFolders="%1(으)로 열기" 작업을 Windows 탐색기 디렉터리의 상황에 맞는 메뉴에 추가 +AssociateWithFiles=%1을(를) 지원되는 파일 형식에 대한 편집기로 등록합니다. +AddToPath=PATH에 추가(다시 시작한 후 사용 가능) +RunAfter=설치 후 %1 실행 +Other=기타: +SourceFile=%1 원본 파일 +OpenWithCodeContextMenu=%1(으)로 열기 +UpdatingVisualStudioCode=Visual Studio Code 업데이트 중... \ No newline at end of file diff --git a/build/win32/i18n/messages.pt-br.isl b/build/win32/i18n/messages.pt-br.isl index e85aede38622a..08c77290f5129 100644 --- a/build/win32/i18n/messages.pt-br.isl +++ b/build/win32/i18n/messages.pt-br.isl @@ -1,9 +1,9 @@ -[CustomMessages] -AddContextMenuFiles=Adicione a ao "Abrir com %1" ao menu de contexto de arquivo do Windows Explorer -AddContextMenuFolders=Adicione a ao "Abrir com %1" ao menu de contexto de diretrio do Windows Explorer +[CustomMessages] +AddContextMenuFiles=Adicione a ação "Abrir com %1" ao menu de contexto de arquivo do Windows Explorer +AddContextMenuFolders=Adicione a ação "Abrir com %1" ao menu de contexto de diretório do Windows Explorer AssociateWithFiles=Registre %1 como um editor para tipos de arquivos suportados -AddToPath=Adicione em PATH (disponvel aps reiniciar) -RunAfter=Executar %1 aps a instalao +AddToPath=Adicione em PATH (disponível após reiniciar) +RunAfter=Executar %1 após a instalação Other=Outros: SourceFile=Arquivo Fonte %1 OpenWithCodeContextMenu=Abrir com %1 diff --git a/build/win32/i18n/messages.ru.isl b/build/win32/i18n/messages.ru.isl index 2b1d906e55dd4..d4f427a11e112 100644 --- a/build/win32/i18n/messages.ru.isl +++ b/build/win32/i18n/messages.ru.isl @@ -1,10 +1,10 @@ -[CustomMessages] -AddContextMenuFiles= " %1" Windows -AddContextMenuFolders= " %1" -AssociateWithFiles= %1 -AddToPath= PATH ( ) -RunAfter= %1 -Other=: -SourceFile= %1 -OpenWithCodeContextMenu= %1 -UpdatingVisualStudioCode= Visual Studio Code... \ No newline at end of file +[CustomMessages] +AddContextMenuFiles=Добавить действие "Открыть с помощью %1" в контекстное меню файла проводника Windows +AddContextMenuFolders=Добавить действие "Открыть с помощью %1" в контекстное меню каталога проводника +AssociateWithFiles=Зарегистрировать %1 в качестве редактора для поддерживаемых типов файлов +AddToPath=Добавить в PATH (доступно после перезагрузки) +RunAfter=Запустить %1 после установки +Other=Другое: +SourceFile=Исходный файл %1 +OpenWithCodeContextMenu=Открыть с помощью %1 +UpdatingVisualStudioCode=Обновление Visual Studio Code... \ No newline at end of file diff --git a/build/win32/i18n/messages.tr.isl b/build/win32/i18n/messages.tr.isl index 5eff39c24a76a..7cc658640c24e 100644 --- a/build/win32/i18n/messages.tr.isl +++ b/build/win32/i18n/messages.tr.isl @@ -1,10 +1,10 @@ -[CustomMessages] -AddContextMenuFiles=Windows Gezgini balam mensne "%1 le A" eylemini ekle -AddContextMenuFolders=Windows Gezgini dizin balam mensne "%1 le A" eylemini ekle -AssociateWithFiles=%1 uygulamasn desteklenen dosya trleri iin bir dzenleyici olarak kayt et -AddToPath=PATH'e ekle (yeniden balattktan sonra kullanlabilir) -RunAfter=Kurulumdan sonra %1 uygulamasn altr. -Other=Dier: -SourceFile=%1 Kaynak Dosyas -OpenWithCodeContextMenu=%1 le A -UpdatingVisualStudioCode=Visual Studio Code gncelleniyor... \ No newline at end of file +[CustomMessages] +AddContextMenuFiles=Windows Gezgini bağlam menüsüne "%1 İle Aç" eylemini ekle +AddContextMenuFolders=Windows Gezgini dizin bağlam menüsüne "%1 İle Aç" eylemini ekle +AssociateWithFiles=%1 uygulamasını desteklenen dosya türleri için bir düzenleyici olarak kayıt et +AddToPath=PATH'e ekle (yeniden başlattıktan sonra kullanılabilir) +RunAfter=Kurulumdan sonra %1 uygulamasını çalıştır. +Other=Diğer: +SourceFile=%1 Kaynak Dosyası +OpenWithCodeContextMenu=%1 İle Aç +UpdatingVisualStudioCode=Visual Studio Code güncelleniyor... \ No newline at end of file diff --git a/build/win32/i18n/messages.zh-cn.isl b/build/win32/i18n/messages.zh-cn.isl index 629bf9ea40135..79e9cdbb59d20 100644 --- a/build/win32/i18n/messages.zh-cn.isl +++ b/build/win32/i18n/messages.zh-cn.isl @@ -1,10 +1,10 @@ -[CustomMessages] -AddContextMenuFiles=ͨ %1 򿪡ӵ Windows ԴļIJ˵ -AddContextMenuFolders=ͨ %1 򿪡ӵ Windows ԴĿ¼IJ˵ -AssociateWithFiles= %1 עΪֵ֧ļ͵ı༭ -AddToPath=ӵ PATH (Ч) -RunAfter=װ %1 -Other=: -SourceFile=%1 Դļ -OpenWithCodeContextMenu=ͨ %1 -UpdatingVisualStudioCode=ڸ Visual Studio Code... \ No newline at end of file +[CustomMessages] +AddContextMenuFiles=将“通过 %1 打开”操作添加到 Windows 资源管理器文件上下文菜单 +AddContextMenuFolders=将“通过 %1 打开”操作添加到 Windows 资源管理器目录上下文菜单 +AssociateWithFiles=将 %1 注册为受支持的文件类型的编辑器 +AddToPath=添加到 PATH (重启后生效) +RunAfter=安装后运行 %1 +Other=其他: +SourceFile=%1 源文件 +OpenWithCodeContextMenu=通过 %1 打开 +UpdatingVisualStudioCode=正在更新 Visual Studio Code... \ No newline at end of file diff --git a/build/win32/i18n/messages.zh-tw.isl b/build/win32/i18n/messages.zh-tw.isl index 8ed1f5a5061d7..d7a95a94750d7 100644 --- a/build/win32/i18n/messages.zh-tw.isl +++ b/build/win32/i18n/messages.zh-tw.isl @@ -1,10 +1,10 @@ -[CustomMessages] -AddContextMenuFiles=N [H %1 }] ʧ@[J Windows ɮ`ɮתާ@\ -AddContextMenuFolders=N [H %1 }] ʧ@[J Windows ɮ`ޥؿާ@\ -AssociateWithFiles=w䴩ɮN %1 Us边 -AddToPath=[J PATH (sҰʫͮ) -RunAfter=w˫ %1 -Other=L: -SourceFile=%1 ӷɮ -OpenWithCodeContextMenu=H %1 } -UpdatingVisualStudioCode=bs Visual Studio Code... \ No newline at end of file +[CustomMessages] +AddContextMenuFiles=將 [以 %1 開啟] 動作加入 Windows 檔案總管檔案的操作功能表中 +AddContextMenuFolders=將 [以 %1 開啟] 動作加入 Windows 檔案總管目錄的操作功能表中 +AssociateWithFiles=針對支援的檔案類型將 %1 註冊為編輯器 +AddToPath=加入 PATH 中 (重新啟動後生效) +RunAfter=安裝後執行 %1 +Other=其他: +SourceFile=%1 來源檔案 +OpenWithCodeContextMenu=以 %1 開啟 +UpdatingVisualStudioCode=正在更新 Visual Studio Code... \ No newline at end of file From a0e2d7088b22420a6d8aa116bfc988ec78ac2b56 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 6 May 2026 16:22:30 -0700 Subject: [PATCH 28/34] Agent Host: Show Open Pull Request button + live PR/CI state (#314860) * Implement Pull Request state for Agent Host sessions Adds an Open Pull Request button + live PR/CI state for Agent Host sessions, mirroring what already works for Extension Host CLI sessions. Server-side (platform/agentHost): - Extend ISessionGitState with githubOwner/githubRepo - agentHostGitService parses 'git remote -v' (preferring origin, falling back to first GitHub remote) and writes {owner, repo} to _meta.git alongside the existing branch/upstream/changes data - Handles SCP-like (git@github.com:o/r), https://, and ssh:// remotes Client-side (sessions/contrib): - IGitHubService.findPullRequestNumberByHeadBranch(owner, repo, branch) uses GET /repos/{o}/{r}/pulls?head={o}:{branch}&state=all to find the most recently updated PR for a head branch; cached in-memory per key - AgentHostSessionAdapter.gitHubInfo is now a derived chain: _meta.git -> {owner, repo, branch} -> PR number lookup -> IGitHubInfo with live PR icon refresh via createPullRequestModelReference + computePullRequestIcon - Thread IGitHubService through local + remote agent-host providers Result: the existing OpenPullRequestAction (gated by ActiveSessionContextKeys.HasPullRequest) now lights up automatically once the session's branch has a PR on GitHub, with the live status icon. Tests: extends agentHostGitService.test.ts with a parseGitHubRepoFromRemote suite covering ssh, https, ssh://, origin-preferred, fallback-to-non-origin, empty input, and non-GitHub remotes. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback - githubService: don't cache transient failures or undefined PR lookups so a later retry succeeds once a PR is created. Successful numeric results are still cached indefinitely (PR number is monotonic). - sessionState: clarify githubOwner/githubRepo doc comments to reflect actual fallback behavior (origin-preferred, otherwise first GitHub remote). - Stub IGitHubService in localAgentHostSessionsProvider and remoteAgentHostSessionsProvider tests. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Hide Sync PR button when there's nothing to sync When the working tree is fully in sync with the open PR, the 'Sync Pull Request' primary button was still shown alongside 'Mark as Done', resulting in two competing primary buttons in the changes view toolbar. Match the EH CLI guard: only show 'Sync Pull Request' when there are incoming, outgoing, or uncommitted changes. This is mutually exclusive with 'Mark as Done' (which requires no such changes). (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/state/sessionState.ts | 8 ++ .../agentHost/node/agentHostGitService.ts | 61 +++++++++++++ .../test/node/agentHostGitService.test.ts | 37 +++++++- .../browser/agentHostSkillButtons.ts | 5 ++ .../browser/baseAgentHostSessionsProvider.ts | 90 +++++++++++++++++-- .../browser/localAgentHostSessionsProvider.ts | 4 +- .../localAgentHostSessionsProvider.test.ts | 4 + .../contrib/github/browser/githubService.ts | 61 +++++++++++++ .../remoteAgentHostSessionsProvider.ts | 4 +- .../remoteAgentHostSessionsProvider.test.ts | 4 + 10 files changed, 267 insertions(+), 11 deletions(-) diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index fd6fb95ed40bf..3574ab1e4d265 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -331,6 +331,10 @@ export interface ISessionGitState { readonly outgoingChanges?: number; /** Number of files with uncommitted changes. */ readonly uncommittedChanges?: number; + /** GitHub repository owner parsed from the working copy's GitHub remote (preferring `origin`, falling back to the first GitHub remote). */ + readonly githubOwner?: string; + /** GitHub repository name parsed from the working copy's GitHub remote (preferring `origin`, falling back to the first GitHub remote). */ + readonly githubRepo?: string; } /** @@ -354,6 +358,8 @@ export function readSessionGitState(meta: SessionMeta | undefined): ISessionGitS incomingChanges?: number; outgoingChanges?: number; uncommittedChanges?: number; + githubOwner?: string; + githubRepo?: string; } = {}; if (typeof raw['hasGitHubRemote'] === 'boolean') { result.hasGitHubRemote = raw['hasGitHubRemote']; } if (typeof raw['branchName'] === 'string') { result.branchName = raw['branchName']; } @@ -362,6 +368,8 @@ export function readSessionGitState(meta: SessionMeta | undefined): ISessionGitS if (typeof raw['incomingChanges'] === 'number') { result.incomingChanges = raw['incomingChanges']; } if (typeof raw['outgoingChanges'] === 'number') { result.outgoingChanges = raw['outgoingChanges']; } if (typeof raw['uncommittedChanges'] === 'number') { result.uncommittedChanges = raw['uncommittedChanges']; } + if (typeof raw['githubOwner'] === 'string') { result.githubOwner = raw['githubOwner']; } + if (typeof raw['githubRepo'] === 'string') { result.githubRepo = raw['githubRepo']; } return result; } diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index c55a9cd5d96ff..ec21c9da49371 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -349,6 +349,7 @@ export class AgentHostGitService implements IAgentHostGitService { const status = parseGitStatusV2(statusOutput); const hasGitHubRemote = parseHasGitHubRemote(remotesOutput); const baseBranchName = parseDefaultBranchRef(defaultBranchRef); + const githubRepo = parseGitHubRepoFromRemote(remotesOutput); // `git status -b --porcelain=v2` only emits ahead/behind counts when the // branch has an upstream tracking ref. For agent-host worktrees the @@ -374,6 +375,8 @@ export class AgentHostGitService implements IAgentHostGitService { incomingChanges: status.incomingChanges, outgoingChanges, uncommittedChanges: status.uncommittedChanges, + githubOwner: githubRepo?.owner, + githubRepo: githubRepo?.repo, }; // Strip undefined fields so the resulting object is the same regardless // of which probes succeeded — easier to compare in tests. @@ -589,6 +592,64 @@ export function parseHasGitHubRemote(remotesOutput: string | undefined): boolean return /github\.com[:\/]/i.test(remotesOutput); } +/** + * Parse `owner` and `repo` from `git remote -v` output. Prefers the `origin` + * remote; falls back to the first GitHub remote so worktrees that renamed + * the remote still surface PR state. Returns `undefined` if no GitHub + * remote is present or the URL doesn't match a GitHub repo shape. + * + * Exported for tests. + */ +export function parseGitHubRepoFromRemote(remotesOutput: string | undefined): { owner: string; repo: string } | undefined { + if (!remotesOutput) { + return undefined; + } + // Each line: `\t ()`. Take fetch URLs only so we + // don't double-count the same remote. + const candidates: { name: string; url: string }[] = []; + for (const rawLine of remotesOutput.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) { continue; } + const m = /^(\S+)\s+(\S+)\s+\(fetch\)$/.exec(line); + if (!m) { continue; } + candidates.push({ name: m[1], url: m[2] }); + } + if (candidates.length === 0) { + return undefined; + } + // Prefer `origin`, otherwise first matching remote. + const ordered = [ + ...candidates.filter(c => c.name === 'origin'), + ...candidates.filter(c => c.name !== 'origin'), + ]; + for (const { url } of ordered) { + const parsed = parseGitHubOwnerRepoFromUrl(url); + if (parsed) { + return parsed; + } + } + return undefined; +} + +/** + * Extract `{owner, repo}` from a GitHub remote URL. Handles the common + * forms: `git@github.com:owner/repo(.git)?`, `https://github.com/owner/repo(.git)?`, + * `ssh://git@github.com/owner/repo(.git)?`, `git://github.com/owner/repo(.git)?`. + */ +function parseGitHubOwnerRepoFromUrl(url: string): { owner: string; repo: string } | undefined { + // SCP-like: git@github.com:owner/repo(.git)? + let m = /^[^@\s]+@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i.exec(url); + if (m) { + return { owner: m[1], repo: m[2] }; + } + // URL-form: ://[user@]github.com[:port]/owner/repo(.git)? + m = /^[a-z+]+:\/\/(?:[^@\/\s]+@)?github\.com(?::\d+)?\/([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i.exec(url); + if (m) { + return { owner: m[1], repo: m[2] }; + } + return undefined; +} + /** Exported for tests. */ export function parseDefaultBranchRef(symbolicRefOutput: string | undefined): string | undefined { const ref = symbolicRefOutput?.trim(); diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts index 957c43f080056..e3ace74059009 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { EMPTY_TREE_OBJECT, getBranchCompletions, parseDefaultBranchRef, parseGitDiffRawNumstat, parseGitStatusV2, parseHasGitHubRemote, parseUntrackedPaths } from '../../node/agentHostGitService.js'; +import { EMPTY_TREE_OBJECT, getBranchCompletions, parseDefaultBranchRef, parseGitDiffRawNumstat, parseGitHubRepoFromRemote, parseGitStatusV2, parseHasGitHubRemote, parseUntrackedPaths } from '../../node/agentHostGitService.js'; import { buildGitBlobUri } from '../../node/gitDiffContent.js'; import { URI } from '../../../../base/common/uri.js'; @@ -119,6 +119,41 @@ suite('AgentHostGitService', () => { }); }); + suite('parseGitHubRepoFromRemote', () => { + test('parses ssh (scp-like) origin remote', () => { + const out = 'origin\tgit@github.com:microsoft/vscode.git (fetch)\norigin\tgit@github.com:microsoft/vscode.git (push)\n'; + assert.deepStrictEqual(parseGitHubRepoFromRemote(out), { owner: 'microsoft', repo: 'vscode' }); + }); + test('parses https origin remote without .git suffix', () => { + const out = 'origin\thttps://github.com/microsoft/vscode (fetch)\n'; + assert.deepStrictEqual(parseGitHubRepoFromRemote(out), { owner: 'microsoft', repo: 'vscode' }); + }); + test('parses ssh:// scheme remote', () => { + const out = 'origin\tssh://git@github.com/microsoft/vscode.git (fetch)\n'; + assert.deepStrictEqual(parseGitHubRepoFromRemote(out), { owner: 'microsoft', repo: 'vscode' }); + }); + test('prefers origin over other remotes', () => { + const out = + 'fork\tgit@github.com:me/vscode.git (fetch)\n' + + 'origin\tgit@github.com:microsoft/vscode.git (fetch)\n'; + assert.deepStrictEqual(parseGitHubRepoFromRemote(out), { owner: 'microsoft', repo: 'vscode' }); + }); + test('falls back to first github remote when origin is not github', () => { + const out = + 'origin\tgit@gitlab.com:foo/bar.git (fetch)\n' + + 'upstream\thttps://github.com/microsoft/vscode.git (fetch)\n'; + assert.deepStrictEqual(parseGitHubRepoFromRemote(out), { owner: 'microsoft', repo: 'vscode' }); + }); + test('returns undefined when no remotes are present', () => { + assert.strictEqual(parseGitHubRepoFromRemote(''), undefined); + assert.strictEqual(parseGitHubRepoFromRemote(undefined), undefined); + }); + test('returns undefined when no GitHub remote is present', () => { + const out = 'origin\thttps://gitlab.com/foo/bar.git (fetch)\n'; + assert.strictEqual(parseGitHubRepoFromRemote(out), undefined); + }); + }); + suite('parseUntrackedPaths', () => { test('returns empty for empty/undefined output', () => { assert.deepStrictEqual(parseUntrackedPaths(undefined), []); diff --git a/src/vs/sessions/contrib/agentHost/browser/agentHostSkillButtons.ts b/src/vs/sessions/contrib/agentHost/browser/agentHostSkillButtons.ts index 59723019d70dc..077d8d3957ac7 100644 --- a/src/vs/sessions/contrib/agentHost/browser/agentHostSkillButtons.ts +++ b/src/vs/sessions/contrib/agentHost/browser/agentHostSkillButtons.ts @@ -138,6 +138,11 @@ const AGENT_HOST_SKILL_BUTTONS: readonly IAgentHostSkillButtonSpec[] = [ ActiveSessionContextKeys.HasGitHubRemote, ActiveSessionContextKeys.HasPullRequest, ActiveSessionContextKeys.HasOpenPullRequest, + ContextKeyExpr.or( + ActiveSessionContextKeys.HasIncomingChanges, + ActiveSessionContextKeys.HasOutgoingChanges, + ActiveSessionContextKeys.HasUncommittedChanges, + ), ), }, ]; diff --git a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts index 49ab0c27c15fa..70e37d5fcf15c 100644 --- a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -6,11 +6,12 @@ import { disposableTimeout, raceTimeout } from '../../../../base/common/async.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; +import { structuralEquals } from '../../../../base/common/equals.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, IReference, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { equals } from '../../../../base/common/objects.js'; -import { constObservable, derived, IObservable, ISettableObservable, observableValue, transaction } from '../../../../base/common/observable.js'; +import { constObservable, derived, derivedOpts, IObservable, ISettableObservable, observableFromPromise, observableValue, transaction } from '../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; @@ -21,7 +22,7 @@ import { ResolveSessionConfigResult } from '../../../../platform/agentHost/commo import { NotificationType } from '../../../../platform/agentHost/common/state/protocol/notifications.js'; import { FileEdit, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionState, SessionSummary } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; -import { readSessionGitState, StateComponents, type ISessionGitState } from '../../../../platform/agentHost/common/state/sessionState.js'; +import { readSessionGitState, SessionMeta, StateComponents, type ISessionGitState } from '../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; @@ -34,6 +35,8 @@ import { agentHostSessionWorkspaceKey } from '../../../common/agentHostSessionWo import { isSessionConfigComplete } from '../../../common/sessionConfig.js'; import { CopilotCLISessionType, IChat, IGitHubInfo, ISession, ISessionChangeset, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, SessionStatus, toSessionId } from '../../../services/sessions/common/session.js'; import { ISendRequestOptions, ISessionChangeEvent } from '../../../services/sessions/common/sessionsProvider.js'; +import { computePullRequestIcon } from '../../github/common/types.js'; +import { IGitHubService } from '../../github/browser/githubService.js'; import { diffsEqual, diffsToChanges, mapProtocolStatus } from './agentHostDiffs.js'; // ============================================================================ @@ -55,6 +58,13 @@ export interface IAgentHostAdapterOptions { readonly buildWorkspace: (project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined, gitState: ISessionGitState | undefined) => ISessionWorkspace | undefined; /** Optional URI mapping for diff entries (remote uses `toAgentHostUri`; local uses identity). */ readonly mapDiffUri?: (uri: URI) => URI; + /** + * GitHub service used to resolve the pull request that targets the + * session's branch and refresh its live state. Optional so tests / hosts + * without a workbench GitHub service still construct adapters; PR + * affordances simply stay dormant when absent. + */ + readonly gitHubService?: IGitHubService; } /** @@ -84,7 +94,7 @@ export class AgentHostSessionAdapter implements ISession { readonly isRead = observableValue('isRead', true); readonly description: IObservable; readonly lastTurnEnd: ISettableObservable; - readonly gitHubInfo = observableValue('gitHubInfo', undefined); + readonly gitHubInfo: IObservable; readonly mainChat: IChat; readonly chats: IObservable; @@ -99,6 +109,12 @@ export class AgentHostSessionAdapter implements ISession { private _project: IAgentSessionMetadata['project']; private _workingDirectory: URI | undefined; private _meta: IAgentSessionMetadata['_meta']; + /** + * Observable mirror of {@link _meta}, kept in sync with every write to + * `_meta` so reactive derivations (notably {@link gitHubInfo}) re-fire + * when git state arrives (or changes). + */ + private readonly _metaObs: ISettableObservable; private _activity: ISettableObservable; constructor( @@ -131,6 +147,7 @@ export class AgentHostSessionAdapter implements ISession { this._project = metadata.project; this._workingDirectory = metadata.workingDirectory; this._meta = metadata._meta; + this._metaObs = observableValue('agentHostSessionMeta', this._meta); const initialGitState = readSessionGitState(this._meta); const initialWorkspace = _options.buildWorkspace(this._project, this._workingDirectory, initialGitState); this.workspace = observableValue('workspace', initialWorkspace); @@ -147,6 +164,57 @@ export class AgentHostSessionAdapter implements ISession { return this._options.description; }); + // gitHubInfo is reactively derived from `_meta.git`. Owner/repo come + // from the agent host's git state; the PR number is resolved by the + // workbench-side GitHub service and the PR's live state (open/closed/ + // merged/draft) is observed so the icon stays current. + const gitHubService = _options.gitHubService; + const gitHubCoords = derivedOpts<{ readonly owner: string; readonly repo: string; readonly branch: string } | undefined>( + { owner: this, equalsFn: structuralEquals }, + reader => { + const git = readSessionGitState(this._metaObs.read(reader)); + if (git?.githubOwner && git?.githubRepo && git?.branchName) { + return { owner: git.githubOwner, repo: git.githubRepo, branch: git.branchName }; + } + return undefined; + }); + const pullRequestNumberObs = derived | undefined>( + this, + reader => { + const coords = gitHubCoords.read(reader); + if (!coords || !gitHubService) { + return undefined; + } + return observableFromPromise( + gitHubService.findPullRequestNumberByHeadBranch(coords.owner, coords.repo, coords.branch) + ); + }); + this.gitHubInfo = derived(this, reader => { + const coords = gitHubCoords.read(reader); + if (!coords) { + return undefined; + } + const innerObs = pullRequestNumberObs.read(reader); + const prNumber = innerObs?.read(reader)?.value; + if (prNumber === undefined) { + return { owner: coords.owner, repo: coords.repo }; + } + const uri = URI.parse(`https://github.com/${coords.owner}/${coords.repo}/pull/${prNumber}`); + let icon: ThemeIcon | undefined; + if (gitHubService) { + const ref = reader.store.add(gitHubService.createPullRequestModelReference(coords.owner, coords.repo, prNumber)); + const livePR = ref.object.pullRequest.read(reader); + if (livePR) { + icon = computePullRequestIcon(livePR.isDraft ? 'draft' : livePR.state); + } + } + return { + owner: coords.owner, + repo: coords.repo, + pullRequest: { number: prNumber, uri, icon }, + }; + }); + if (metadata.isRead === false) { this.isRead.set(false, undefined); } @@ -219,6 +287,7 @@ export class AgentHostSessionAdapter implements ISession { // exclusively via `setMeta` from `SessionState` subscription updates. if (metadata._meta !== undefined) { this._meta = metadata._meta; + this._metaObs.set(this._meta, tx); } const workspace = this._options.buildWorkspace(this._project, this._workingDirectory, readSessionGitState(this._meta)); if (agentHostSessionWorkspaceKey(workspace) !== agentHostSessionWorkspaceKey(this.workspace.get())) { @@ -279,11 +348,14 @@ export class AgentHostSessionAdapter implements ISession { this._meta = meta; const gitState = readSessionGitState(this._meta); const workspace = this._options.buildWorkspace(this._project, this._workingDirectory, gitState); - if (agentHostSessionWorkspaceKey(workspace) === agentHostSessionWorkspaceKey(this.workspace.get())) { - return false; - } - this.workspace.set(workspace, undefined); - return true; + const workspaceChanged = agentHostSessionWorkspaceKey(workspace) !== agentHostSessionWorkspaceKey(this.workspace.get()); + transaction(tx => { + this._metaObs.set(this._meta, tx); + if (workspaceChanged) { + this.workspace.set(workspace, tx); + } + }); + return workspaceChanged; } } @@ -705,6 +777,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement @ILanguageModelsService protected readonly _languageModelsService: ILanguageModelsService, @IConfigurationService protected readonly _baseConfigurationService: IConfigurationService, @ILogService protected readonly _logService: ILogService, + @IGitHubService protected readonly _gitHubService: IGitHubService, ) { super(); } @@ -734,6 +807,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement icon: this.icon, loading: this.authenticationPending, mapDiffUri: this._diffUriMapper(), + gitHubService: this._gitHubService, ...this._adapterOptions(), }); } diff --git a/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts index 48319dfbe44a9..f307eb2d35cab 100644 --- a/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts @@ -25,6 +25,7 @@ import { buildAgentHostSessionWorkspace, readBranchProtectionPatterns } from '.. import { ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL } from '../../../services/sessions/common/session.js'; import { toAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js'; import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../common/agentHostSessionsProvider.js'; +import { IGitHubService } from '../../github/browser/githubService.js'; const LOCAL_RESOURCE_SCHEME_PREFIX = 'agent-host-'; @@ -56,8 +57,9 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide @ILabelService private readonly _labelService: ILabelService, @IConfigurationService private readonly _configurationService: IConfigurationService, @ILogService logService: ILogService, + @IGitHubService gitHubService: IGitHubService, ) { - super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService); + super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService, gitHubService); this.label = localize('localAgentHostLabel', "Local Agent Host"); diff --git a/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 08040c40fb5c9..1497e4c857bc0 100644 --- a/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -32,6 +32,7 @@ import { SessionStatus } from '../../../../services/sessions/common/session.js'; import { LocalAgentHostSessionsProvider } from '../../browser/localAgentHostSessionsProvider.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { IGitHubService } from '../../../github/browser/githubService.js'; // ---- Mock IAgentHostService ------------------------------------------------- @@ -247,6 +248,9 @@ function createProvider(disposables: DisposableStore, agentHostService: MockAgen getUriLabel: (uri: URI) => uri.path, }); instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IGitHubService, new class extends mock() { + override findPullRequestNumberByHeadBranch = async () => undefined; + }()); return disposables.add(instantiationService.createInstance(LocalAgentHostSessionsProvider)); } diff --git a/src/vs/sessions/contrib/github/browser/githubService.ts b/src/vs/sessions/contrib/github/browser/githubService.ts index 06157b33238f7..c950713444924 100644 --- a/src/vs/sessions/contrib/github/browser/githubService.ts +++ b/src/vs/sessions/contrib/github/browser/githubService.ts @@ -48,6 +48,17 @@ export interface IGitHubService { * List files changed between two refs using the GitHub compare API. */ getChangedFiles(owner: string, repo: string, base: string, head: string): Promise; + + /** + * Find the most recently updated pull request whose head branch is + * `branch` in `owner/repo`. Returns `undefined` if no PR exists. + * + * Successful numeric results are cached per `(owner, repo, branch)` + * for the lifetime of the service (PR number is monotonic per + * branch lifetime). Transient failures and `undefined` results are + * not cached, so a later retry can succeed once a PR is created. + */ + findPullRequestNumberByHeadBranch(owner: string, repo: string, branch: string): Promise; } export const IGitHubService = createDecorator('sessionsGitHubService'); @@ -65,6 +76,16 @@ export class GitHubService extends Disposable implements IGitHubService { private readonly _pullRequestReferences: GitHubPullRequestModelReferenceCollection; private readonly _pullRequestReviewThreadsReferences: GitHubPullRequestReviewThreadsModelReferenceCollection; private readonly _pullRequestCIReferences: GitHubPullRequestCIModelReferenceCollection; + private readonly _apiClient: GitHubApiClient; + + /** + * Cache of in-flight / resolved `findPullRequestNumberByHeadBranch` + * lookups, keyed by `${owner}/${repo}#${branch}`. Promises are kept + * indefinitely — PR-number assignment is monotonic for the lifetime of + * a branch, and live PR state (open/closed/draft, CI) is refreshed via + * `createPullRequestModelReference` once we know the number. + */ + private readonly _findPRByBranchCache = new Map>(); constructor( @IInstantiationService instantiationService: IInstantiationService, @@ -73,6 +94,7 @@ export class GitHubService extends Disposable implements IGitHubService { super(); const apiClient = this._register(instantiationService.createInstance(GitHubApiClient)); + this._apiClient = apiClient; this._changesFetcher = new GitHubChangesFetcher(apiClient); @@ -169,4 +191,43 @@ export class GitHubService extends Disposable implements IGitHubService { getChangedFiles(owner: string, repo: string, base: string, head: string): Promise { return this._changesFetcher.getChangedFiles(owner, repo, base, head); } + + findPullRequestNumberByHeadBranch(owner: string, repo: string, branch: string): Promise { + const key = `${owner}/${repo}#${branch}`; + let promise = this._findPRByBranchCache.get(key); + if (!promise) { + promise = this._fetchPullRequestNumberByHeadBranch(owner, repo, branch); + this._findPRByBranchCache.set(key, promise); + // Only cache successful, numeric results indefinitely; the PR number + // for a given (owner, repo, branch) is monotonic for that branch's + // lifetime so it's safe to cache forever. For transient failures and + // "no PR yet" results, drop the cache entry so the next call retries. + promise.then( + value => { + if (typeof value !== 'number') { + this._findPRByBranchCache.delete(key); + } + }, + () => { + this._findPRByBranchCache.delete(key); + }, + ); + } + return promise.catch(() => undefined); + } + + private async _fetchPullRequestNumberByHeadBranch(owner: string, repo: string, branch: string): Promise { + // Use the REST `pulls` list API filtered by `head=${owner}:${branch}`. + // Default state is `open`; we include closed/merged so the button still + // surfaces the PR after the agent run finishes and the PR is merged. + // `per_page=1` + `sort=updated` gives us the most recent match. + const path = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?head=${encodeURIComponent(`${owner}:${branch}`)}&state=all&sort=updated&direction=desc&per_page=1`; + const response = await this._apiClient.request( + 'GET', + path, + 'githubApi.findPullRequestByHeadBranch', + ); + const first = response.data?.[0]; + return first ? first.number : undefined; + } } diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index c3831b5453d3c..f79bc4fb31407 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -31,6 +31,7 @@ import { IChatService } from '../../../../workbench/contrib/chat/common/chatServ import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; import { AgentHostSessionAdapter, BaseAgentHostSessionsProvider } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; +import { IGitHubService } from '../../github/browser/githubService.js'; import { buildAgentHostSessionWorkspace, readBranchProtectionPatterns } from '../../../common/agentHostSessionWorkspace.js'; import { ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../services/sessions/common/session.js'; import { remoteAgentHostSessionTypeId } from '../common/remoteAgentHostSessionType.js'; @@ -202,8 +203,9 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid @ILabelService private readonly _labelService: ILabelService, @IConfigurationService private readonly _configurationService: IConfigurationService, @ILogService logService: ILogService, + @IGitHubService gitHubService: IGitHubService, ) { - super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService); + super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService, gitHubService); this._connectionAuthority = agentHostAuthority(config.address); this._connectOnDemand = config.connectOnDemand; diff --git a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index fdfa8504a852b..37780d308f801 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -33,6 +33,7 @@ import { SessionStatus, COPILOT_CLI_SESSION_TYPE } from '../../../../services/se import { RemoteAgentHostSessionsProvider, type IRemoteAgentHostSessionsProviderConfig } from '../../browser/remoteAgentHostSessionsProvider.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { IGitHubService } from '../../../github/browser/githubService.js'; // ---- Mock connection -------------------------------------------------------- @@ -211,6 +212,9 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne getUriLabel: (uri: URI) => uri.path, }); instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IGitHubService, new class extends mock() { + override findPullRequestNumberByHeadBranch = async () => undefined; + }()); const config: IRemoteAgentHostSessionsProviderConfig = { address: overrides?.address ?? 'localhost:4321', From 79ca5c30c53b7862363ad87d0db2c7656796d933 Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst <39639992+hawkticehurst@users.noreply.github.com> Date: Wed, 6 May 2026 19:30:59 -0400 Subject: [PATCH 29/34] sessions: match panel tabs to auxiliary bar (#314843) * sessions: match panel tabs to auxiliary bar Reuse the same title-tab treatment for the sessions panel that the Changes/Files tabs already use in the auxiliary bar. This removes the persistent active underline, switches labels to sentence case, tightens the pill spacing, and bumps the label weight to 500. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: correct panel tab casing docs Update the sessions layout spec to describe the existing panel and auxiliary bar tab labels as title case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/LAYOUT.md | 3 ++- .../browser/parts/media/panelPart.css | 26 ++++++++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 104ef9e282024..bb1e79142b19c 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -386,7 +386,7 @@ The Agent Sessions workbench uses specialized part implementations that extend t |---------|----------------|---------------------| | Activity Bar integration | Full support | No activity bar; account widget in the titlebar | | Composite bar position | Configurable (top/bottom/title/hidden) | Fixed: Title | -| Composite bar visibility | Configurable | Sidebar: hidden (`shouldShowCompositeBar()` returns `false`); ChatBar: hidden; Auxiliary Bar & Panel: visible. Separately, the internal chat tab strip shown inside the Chat Bar preserves each chat title's original casing instead of forcing per-word capitalization via CSS. | +| Composite bar visibility | Configurable | Sidebar: hidden (`shouldShowCompositeBar()` returns `false`); ChatBar: hidden; Auxiliary Bar & Panel: visible. The Auxiliary Bar and Panel title tabs share the same pill treatment: title-case labels, 500 font weight, compact horizontal padding, checked-state background, and no persistent active underline outside keyboard focus. Separately, the internal chat tab strip shown inside the Chat Bar preserves each chat title's original casing instead of forcing per-word capitalization via CSS. | | Auto-hide support | Configurable | Disabled | | Configuration listening | Many settings | Minimal | | Context menu actions | Full set | Simplified | @@ -666,6 +666,7 @@ interface IPartVisibilityState { | Date | Change | |------|--------| +| 2026-05-06 | Updated the sessions panel title tabs to reuse the same styling as the auxiliary bar's Changes/Files tabs: title-case labels, 500 font weight, tighter pill padding, and checked-state background without a persistent active underline. | | 2026-05-01 | Updated the sessions main-editor lifecycle so maximized editors attached to the auxiliary bar remember their maximized state across close/reopen, while modal editor flows continue to ignore that remembered state. | | 2026-04-28 | Updated the sessions "Open in VS Code" titlebar widget to match the core "Open in Agents" affordance more closely: the product icon is greyscale by default, animates back to full color on hover/focus when motion is enabled, uses secondary-button hover chrome instead of quality-tinted backgrounds, and draws a separator before the Run split button. | | 2026-04-27 | Made the sessions shell gradient background the default treatment by removing the `sessions.experimental.shellGradientBackground` opt-in, always applying the root shell gradient layer, and renaming the workbench CSS hook to `shell-gradient-background`. | diff --git a/src/vs/sessions/browser/parts/media/panelPart.css b/src/vs/sessions/browser/parts/media/panelPart.css index 88a9d4a66f0c2..810e10b19c63f 100644 --- a/src/vs/sessions/browser/parts/media/panelPart.css +++ b/src/vs/sessions/browser/parts/media/panelPart.css @@ -10,16 +10,28 @@ /* ===== Modern action label styling for sessions panel ===== */ -/* Hide the underline indicator for non-focused, non-checked items; keep it for focus-visible and checked */ -.agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(:focus-visible):not(.checked) .active-item-indicator:before { +/* Base label: lowercase text + heavier weight + pill padding */ +.agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon) .action-label { + text-transform: capitalize; + font-weight: 500; + border-radius: 4px; + padding: 0px 8px; + font-size: 12px; + line-height: 22px; +} + +.agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item { + padding-left: 0; + padding-right: 0; +} + +/* Hide the underline indicator for non-focused items, but keep it for keyboard focus */ +.agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(:focus-visible) .active-item-indicator:before { display: none !important; } .agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus-visible .active-item-indicator:before { display: block !important; } -.agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .active-item-indicator:before { - display: block !important; -} /* Make icon action items 24px tall */ .agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon { @@ -27,8 +39,8 @@ } .agent-sessions-workbench .part.panel > .title { - padding-left: 6px; - padding-right: 6px; + padding-left: 4px; + padding-right: 2px; background-color: var(--vscode-agentsPanel-background); } From 468fa08b4f3f8bd93ba450ff5a9a653fd2db4eb5 Mon Sep 17 00:00:00 2001 From: Paul Date: Wed, 6 May 2026 16:47:26 -0700 Subject: [PATCH 30/34] Don't skip quota bucket when hasQuota: false (#314874) --- .../common/chatEntitlementService.test.ts | 93 +++++++++++++++++-- .../chat/common/chatEntitlementService.ts | 11 ++- 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts index db38c863ed30b..e15bf6a7f3348 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts @@ -185,7 +185,88 @@ suite('parseQuotas', () => { assert.strictEqual(quotas.additionalUsageEnabled, false); }); - test('skips quota snapshots with has_quota false', () => { + test('keeps TBB snapshots: unlimited with zero entitlement and finite with nonzero entitlement (has_quota is always false)', () => { + const data = makeEntitlementsData({ + access_type_sku: 'monthly_subscriber_quota', + copilot_plan: 'individual', + token_based_billing: true, + quota_snapshots: { + chat: { + overage_count: 0, + overage_permitted: false, + percent_remaining: 100, + unlimited: true, + entitlement: '0', + has_quota: false, + }, + completions: { + overage_count: 0, + overage_permitted: false, + percent_remaining: 100, + unlimited: true, + entitlement: '0', + has_quota: false, + }, + premium_interactions: { + overage_count: 0, + overage_permitted: false, + percent_remaining: 5.5, + unlimited: false, + entitlement: '1000', + has_quota: false, + }, + }, + }); + + const quotas = parseQuotas(data); + assert.strictEqual(quotas.chat?.percentRemaining, 100); + assert.strictEqual(quotas.chat?.unlimited, true); + assert.strictEqual(quotas.completions?.percentRemaining, 100); + assert.strictEqual(quotas.completions?.unlimited, true); + assert.strictEqual(quotas.premiumChat?.percentRemaining, 5.5); + assert.strictEqual(quotas.premiumChat?.entitlement, 1000); + }); + + test('keeps all snapshots for CB/CE users where all categories are unlimited', () => { + const data = makeEntitlementsData({ + access_type_sku: 'copilot_enterprise_seat_multi_quota', + copilot_plan: 'enterprise', + token_based_billing: true, + quota_snapshots: { + chat: { + overage_count: 0, + overage_permitted: false, + percent_remaining: 100, + unlimited: true, + entitlement: '0', + has_quota: false, + }, + completions: { + overage_count: 0, + overage_permitted: false, + percent_remaining: 100, + unlimited: true, + entitlement: '0', + has_quota: false, + }, + premium_interactions: { + overage_count: 0, + overage_permitted: false, + percent_remaining: 100, + unlimited: true, + entitlement: '0', + has_quota: false, + }, + }, + }); + + const quotas = parseQuotas(data); + assert.strictEqual(quotas.chat?.unlimited, true); + assert.strictEqual(quotas.completions?.unlimited, true); + assert.strictEqual(quotas.premiumChat?.unlimited, true); + }); + + test('skips quota snapshots with zero entitlement and not unlimited (e.g. free tier premium_interactions)', () => { const data = makeEntitlementsData({ access_type_sku: 'free_limited_copilot', copilot_plan: 'free', @@ -194,10 +275,10 @@ suite('parseQuotas', () => { chat: { overage_count: 0, overage_permitted: false, - percent_remaining: 97.8, + percent_remaining: 98.7, unlimited: false, entitlement: '200', - has_quota: true, + has_quota: false, }, completions: { overage_count: 0, @@ -205,10 +286,10 @@ suite('parseQuotas', () => { percent_remaining: 100, unlimited: false, entitlement: '4000', - has_quota: true, + has_quota: false, }, premium_interactions: { - overage_count: 999700, + overage_count: 0, overage_permitted: false, percent_remaining: 0, unlimited: false, @@ -219,7 +300,7 @@ suite('parseQuotas', () => { }); const quotas = parseQuotas(data); - assert.strictEqual(quotas.chat?.percentRemaining, 97.8); + assert.strictEqual(quotas.chat?.percentRemaining, 98.7); assert.strictEqual(quotas.chat?.entitlement, 200); assert.strictEqual(quotas.completions?.percentRemaining, 100); assert.strictEqual(quotas.completions?.entitlement, 4000); diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index b27e2e0f3d4c9..172d599376994 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -710,10 +710,19 @@ export function parseQuotas(entitlementsData: IEntitlementsData): IQuotas { if (entitlementsData.quota_snapshots) { for (const quotaType of ['chat', 'completions', 'premium_interactions'] as const) { const rawQuotaSnapshot = entitlementsData.quota_snapshots[quotaType]; - if (!rawQuotaSnapshot || rawQuotaSnapshot.has_quota === false) { + if (!rawQuotaSnapshot) { continue; } const parsedEntitlement = rawQuotaSnapshot.entitlement !== undefined ? Number(rawQuotaSnapshot.entitlement) : undefined; + + // Skip snapshots where the user has no allocated entitlement for this + // category (e.g. free tier premium_interactions with 0 credits). Under + // TBB, has_quota is always false at the per-snapshot level so we cannot + // rely on it; instead check the actual entitlement value. + if (!rawQuotaSnapshot.unlimited && parsedEntitlement === 0) { + continue; + } + const quotaSnapshot: IQuotaSnapshot = { percentRemaining: Math.min(100, Math.max(0, rawQuotaSnapshot.percent_remaining)), unlimited: rawQuotaSnapshot.unlimited, From c9b1f88ce76dda966a26c73924c8768a12bb2b2a Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Wed, 6 May 2026 16:48:32 -0700 Subject: [PATCH 31/34] =?UTF-8?q?agentHost/claude:=20Phase=205=20+=20Phase?= =?UTF-8?q?=206=20=E2=80=94=20IAgent=20provider,=20sendMessage=20(re-land)?= =?UTF-8?q?=20(#314533)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * agentHost/claude: post-Phase-4 cleanup - roadmap.md: mark Phase 4 as DONE, link merged PR #313780. - phase4-plan.md: record live-system smoke completion in §7.8; disabled-gate run skipped (covered by unit tests + env-var guard). - claudeAgent.test.ts: drop gratuitous 'as unknown as' cast in the CCAModel fixture (literal already matches CCAModelBilling exactly; plan §7.4 forbids unsafe casts in tests). * agentHost/claude: lock Phase 5 implementation plan Handoff plan for Phase 5 (replace 7 throwing stubs in claudeAgent.ts). Locked against post-PR-#313841 reality (provisional sessions, onDidMaterializeSession, 30s empty-session GC) and the IAgent contract on origin/main. Decisions captured: - Non-fork createSession is synchronous and in-memory; fork deferred to Phase 6 (throws TODO). - IClaudeAgentSdkService surface mirrors IAgent (no dir parameter on listSessions); SDK loader caches resolved module, retries on failure, logs once. - listSessions joins SDK enumeration with workbench session DB metadata via ISessionDataService; per-entry try/catch resilience. - shutdown() routes per-session teardown through the same SequencerByKey used by disposeSession() so concurrent shutdown/disposeSession cannot double-dispose a wrapper in Phase 6. - 14 unit tests defined (12 lifecycle + 2 resolved-config), including log-once contract and shutdown/disposeSession race guard. * agentHost/claude: Phase 5 — IAgent provider skeleton Lands the ClaudeAgent IAgent provider behind the 'chat.agentHost.claudeAgent.enabled' setting (env gate VSCODE_AGENT_HOST_ENABLE_CLAUDE=1). Pins @anthropic-ai/claude-agent-sdk@0.2.112 in workspace + remote/. Implemented in this phase: * createSession - non-fork, in-memory wrapper only. Honors config.session for restore. The fork path and SDK session creation are deferred to Phase 6. * listSessions - SDK is source of truth; per-session DB read is a best-effort overlay (failure never excludes an entry). * disposeSession / shutdown - routed through a per-session SequencerByKey to serialize teardown. * getDescriptor, getProtectedResources, models, onDidSessionProgress, setClientCustomizations, setClientTools, onClientToolCallComplete, setCustomizationEnabled, authenticate, respondTo*Request - minimal Phase-5 wiring. Stubbed for Phase 6 (throw async 'TODO: Phase 6'): sendMessage, abortSession, changeModel, getSessionMessages, plus the createSession fork path. Tests: 29 unit tests in claudeAgent.test.ts cover the createSession restore-id path, listSessions overlay resilience, dispose serialization, and stub surfaces. Note: provisional / onDidMaterializeSession is intentionally omitted in Phase 5 (see plan section 3.3.1) - the workbench needs an immediate sessionAdded until the agent has real materialization work, which arrives in Phase 6 alongside SDK query() startup. * agentHost/claude: Phase 6 — sendMessage, single-turn, no tools Implements the Phase 6 plan: provisional sessions materialize on first sendMessage, route a single-turn prompt through the Anthropic Claude Agent SDK's WarmQuery, and stream SDKMessages back as protocol AgentSignals via a pure mapSDKMessageToAgentSignals reducer. Tools remain denied (canUseTool: 'deny'); fork moves to Phase 6.5; Plan Mode UI moves to Phase 7. Highlights: - ClaudeAgent.sendMessage routes through _sessionSequencer to collapse concurrent first sends into one materialize + N ordered sends. - _materializeProvisional has two abort gates (post-startup + post-customizationDirectory write) so disposeSession landing mid-materialize cannot leak a WarmQuery subprocess. - ClaudeAgentSession owns the prompt iterator + per-turn deferreds; mapSDKMessageToAgentSignals is a pure reducer with state owned by the session. - IClaudeAgentSdkService gains startup() alongside listSessions(). Tests: 43 unit + 2 proxy-backed integration. Council-review fixes (C1 dispose race, C2 missing integration test, S1 cwd-less ratification) included. * agentHost/claude: address PR review (listSessions resilience, dispose abort) Two Copilot-reviewer comments on #314216: 1. listSessions: wrap _sdkService.listSessions() in try/catch. AgentService.listSessions fans out across providers via Promise.all; an SDK dynamic-import failure would otherwise nuke every other provider's session list. Now logs and returns []. 2. dispose: abort _provisionalSessions AbortControllers before super.dispose(). Previously a racing first sendMessage parked inside _writeCustomizationDirectory could pass the materialize abort gates and call _sessions.set on a disposed DisposableMap, orphaning the WarmQuery. Aborting first triggers the existing post-customization-write abort gate, which asyncDisposes the WarmQuery. Tests: 2 new regressions (listSessions empty on SDK throw; agent.dispose() during racing materialize disposes the WarmQuery). 45/45 unit + 2/2 integration pass. * Drop stale @anthropic-ai/sandbox-runtime dep from merge resolution * Bump @anthropic-ai/claude-agent-sdk 0.2.112 → 0.2.128 The new SDK no longer vendors native binaries inside the main package. It now ships a ~200MB `claude` executable per platform via 8 optional platform-specific packages, mirroring the @github/copilot pattern: @anthropic-ai/claude-agent-sdk-{darwin,win32}-{x64,arm64} @anthropic-ai/claude-agent-sdk-linux-{x64,arm64}{,-musl} The SDK loader picks the right package at runtime via process.platform /process.arch (and tries -musl first on linux). To strip off-target packages from the build output: - build/lib/claudeAgentSdk.ts mirrors build/lib/copilot.ts - gulpfile.vscode.ts and gulpfile.reh.ts apply the filter alongside the existing copilot one - gulpfile.vscode.ts asar-unpacks @anthropic-ai/claude-agent-sdk-* so the executable stays on disk (asar would break exec permissions) - alpine-{arch} maps to linux-{arch}-musl (claude is statically linked against libc and must match the host) cglicenses.json gets 8 new entries mirroring the parent SDK's "© Anthropic PBC. All rights reserved." text. The new SDK Query interface adds a `readFile` method; FakeQuery and RoundTripQuery test doubles get matching stubs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * build: cross-copy @anthropic-ai/claude-agent-sdk darwin packages for universal app Mirror the existing @github/copilot pattern in create-universal-app.ts. Each darwin arch build only contains its own platform package (npm only installs the optionalDependency matching the host CPU), so the universal merger fails with files unique to one side. Cross-copy node_modules/@anthropic-ai/claude-agent-sdk-{darwin-x64,darwin-arm64}/ between the two builds, skip them from the equality comparison, and tag the per-arch `claude` executable as arch-specific so the merger keeps both. Also extend verify-macho's skip list to ignore the single-arch `claude` binaries inside the universal app. * agentHost/claude: address PR review - claudeAgent: rewrite `rgPath` from node_modules.asar → node_modules.asar.unpacked before putting it on the Claude subprocess PATH. Mirrors copilotAgent.ts and the workbench search engine helpers; without this, packaged builds advertise a path that doesn't exist on disk. - cglicenses.json: add "// Reason: …" justification comments to the parent @anthropic-ai/claude-agent-sdk entry and each of the 8 new platform sub-packages, matching the file's convention. * build: bump DMG volume size from 1g to 2g The universal macOS app now carries platform-specific binaries for both x64 and arm64 — @github/copilot-darwin-* (~128MB each) and the new @anthropic-ai/claude-agent-sdk-darwin-* (~207MB each) — so the source filesystem the DMG is built from has crossed 1GB. dmgbuild fails with `No space left on device` when ditto can't fit the app inside the volume. Output DMG is LZMA-compressed (format = 'ULMO') so this only changes the build-time staging size, not the shipped artifact size. * agentHost/claude: don't bundle claude-agent-sdk; load it from a user-specified path Move `@anthropic-ai/claude-agent-sdk` from `dependencies` to `devDependencies` so the ~200MB-per-arch platform binaries are no longer shipped with VS Code. The SDK becomes opt-in and externally-installed. User-facing surface: - Replace boolean setting `chat.agentHost.claudeAgent.enabled` with string setting `chat.agentHost.claudeAgent.path`. When the setting is non-empty, the Claude provider registers; when empty (the default), it does not. - The setting value is forwarded to the agent host via the `VSCODE_AGENT_HOST_CLAUDE_SDK_PATH` env var (replacing the previous `VSCODE_AGENT_HOST_ENABLE_CLAUDE` flag). - `agentHostServerMain` exposes a `--claude-sdk-path ` CLI flag in place of the previous `--enable-claude-agent` flag. Runtime loader: - `ClaudeAgentSdkService._loadSdk()` now reads the env var and dynamic-imports from there. If the path is a directory, the package's main entry is resolved from `package.json` (`exports['.']` / `main`) before the import — Node ESM does not support directory imports of `file://` URLs. Build/packaging cleanup (no longer needed once the SDK is gone from production deps): - Drop `build/lib/claudeAgentSdk.ts` and its callers in `gulpfile.{vscode,reh}.ts`. - Drop the `@anthropic-ai/claude-agent-sdk-*` glob from the asar-unpack list in `gulpfile.vscode.ts`. - Revert universal-app cross-copy + filesToSkip + x64ArchFiles entries in `build/darwin/create-universal-app.ts` and the corresponding skip patterns in `build/darwin/verify-macho.ts`. - Revert DMG volume size from 2g back to 1g in `build/darwin/dmg-settings.py.template` (was bumped earlier in this branch to fit the bundled SDK; no longer needed). - Remove the 9 `@anthropic-ai/claude-agent-sdk*` entries from `cglicenses.json` (no longer shipped, no manifest to license). Type imports of `@anthropic-ai/claude-agent-sdk` continue to work via the devDependency, so source code that does `import type` from the package still typechecks. * revert cglicenses change --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eslint.config.js | 3 +- package-lock.json | 161 ++ package.json | 1 + .../platform/agentHost/common/agentService.ts | 26 +- .../common/claudeSessionConfigKeys.ts | 30 + .../electron-main/electronAgentHostStarter.ts | 15 +- .../platform/agentHost/node/agentHostMain.ts | 13 +- .../agentHost/node/agentHostServerMain.ts | 28 +- .../agentHost/node/claude/claudeAgent.ts | 655 +++++- .../node/claude/claudeAgentSdkService.ts | 149 ++ .../node/claude/claudeAgentSession.ts | 268 +++ .../node/claude/claudeMapSessionEvents.ts | 217 ++ .../node/claude/claudePromptResolver.ts | 74 + .../agentHost/node/claude/phase4-plan.md | 12 +- .../agentHost/node/claude/phase5-plan.md | 560 +++++ .../agentHost/node/claude/phase6-plan.md | 944 ++++++++ .../platform/agentHost/node/claude/roadmap.md | 6 +- .../platform/agentHost/node/claude/smoke.md | 127 +- .../agentHost/node/nodeAgentHostStarter.ts | 16 +- .../test/node/claudeAgent.integrationTest.ts | 595 +++++ .../agentHost/test/node/claudeAgent.test.ts | 1936 ++++++++++++++++- .../contrib/chat/browser/chat.contribution.ts | 10 +- 22 files changed, 5729 insertions(+), 117 deletions(-) create mode 100644 src/vs/platform/agentHost/common/claudeSessionConfigKeys.ts create mode 100644 src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts create mode 100644 src/vs/platform/agentHost/node/claude/claudeAgentSession.ts create mode 100644 src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts create mode 100644 src/vs/platform/agentHost/node/claude/claudePromptResolver.ts create mode 100644 src/vs/platform/agentHost/node/claude/phase5-plan.md create mode 100644 src/vs/platform/agentHost/node/claude/phase6-plan.md create mode 100644 src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts diff --git a/eslint.config.js b/eslint.config.js index 7ff8a911059af..dad155b993999 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1632,7 +1632,8 @@ export default tseslint.config( '@xterm/headless', // node module allowed even in /common/ '@vscode/tree-sitter-wasm', // used by agentHost for command auto-approval '@vscode/copilot-api', // used by agentHost for Copilot API requests - '@anthropic-ai/sdk' // used by agentHost for Anthropic API requests + '@anthropic-ai/sdk', // used by agentHost for Anthropic API requests + '@anthropic-ai/claude-agent-sdk' // used by agentHost for Claude Agent SDK session enumeration / queries ] }, { diff --git a/package-lock.json b/package-lock.json index bacab0d895ebb..4261db48d6239 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,6 +72,7 @@ "yazl": "^2.4.3" }, "devDependencies": { + "@anthropic-ai/claude-agent-sdk": "0.2.128", "@playwright/cli": "^0.1.9", "@playwright/test": "^1.56.1", "@stylistic/eslint-plugin-ts": "^2.8.0", @@ -177,6 +178,166 @@ "windows-foreground-love": "0.6.1" } }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.2.128", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.128.tgz", + "integrity": "sha512-KI7H9bocPahGDrrQGME5Eh5a4RTqGrN1fQ69uLs6Ik4icXBZXouCx4Ecum450jMVy58myeh9ahYYLlpDAbQXPA==", + "dev": true, + "license": "SEE LICENSE IN README.md", + "dependencies": { + "@anthropic-ai/sdk": "^0.81.0", + "@modelcontextprotocol/sdk": "^1.29.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.128", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.128", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.128", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.128", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.128", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.128", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.128", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.128" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { + "version": "0.2.128", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.2.128.tgz", + "integrity": "sha512-RAzmB1ls+GWA/YiyfZLWdFYmj3md5emk7mCEeiKSKl2UN4i+tDWy2m/hjIvMFIzBqJJeGmZZSMnf3S0sL/GbhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { + "version": "0.2.128", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.2.128.tgz", + "integrity": "sha512-dDPJHxUhL2sgIB8Q2AnBi4xsApImeW0zf1nbL7gBNSc9RWhGoGQAbPm0KaQ7/03jdom30z1VT5VMhQ5KeEYOIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { + "version": "0.2.128", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.2.128.tgz", + "integrity": "sha512-+GbB33eJSlZUWs84nsibY2nyAFQT96WYLGCteVn62Vv6ZK90NrZsm7lwurjw7oYNnvpzXorhZ2/XpQnWvOK6aQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { + "version": "0.2.128", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.2.128.tgz", + "integrity": "sha512-ZCZEg42St0SCMMZFCvEtkF1LBFMYBxJRXzRno+12vOYYhC6R0l8jPjlgA2ZkN2Lb+TCEOO3fjeWJdZLL/NDM4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { + "version": "0.2.128", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.2.128.tgz", + "integrity": "sha512-aBBXD6OLN/lq9S1p+BNjuEml0lYIoHunFdzFl49B0fsxEAnz1RfJDrpSNpIUAaL5FMZIaFvLqXtbFRy41N2fxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { + "version": "0.2.128", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.2.128.tgz", + "integrity": "sha512-sUSJEtvEt2iiMvgUuBGmBJjLhwHxDKOxVBSsXZaY46KAv3ZwLtLuc5xv2XFHId1B5+nMh7b7mr+HAiBmbMUODA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { + "version": "0.2.128", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.2.128.tgz", + "integrity": "sha512-9Ao2J5KgfkfKxUZK3dbQEGonPYcbUyn7Cn7ykZuP91FN/5ux3Tz90YRJW6UtZdWHoDkmFF0FS8P/jiZuyWPLfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { + "version": "0.2.128", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.2.128.tgz", + "integrity": "sha512-7oxPkgjw1vPZbx6+Qwt9mGouqfpRz5jDcuQ37koayzMdTVzmgCsKAqqbJSpOQfkFGv6gTjcrLWBlk3oapZfBYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/sdk": { + "version": "0.81.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.81.0.tgz", + "integrity": "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@anthropic-ai/sdk": { "version": "0.82.0", "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.82.0.tgz", diff --git a/package.json b/package.json index 3027f8c780180..e18eb7620f19a 100644 --- a/package.json +++ b/package.json @@ -149,6 +149,7 @@ "yazl": "^2.4.3" }, "devDependencies": { + "@anthropic-ai/claude-agent-sdk": "0.2.128", "@playwright/cli": "^0.1.9", "@playwright/test": "^1.56.1", "@stylistic/eslint-plugin-ts": "^2.8.0", diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 64c2c80748fff..aa406a121228b 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -37,20 +37,26 @@ export const AgentHostEnabledSettingId = 'chat.agentHost.enabled'; export const AgentHostIpcLoggingSettingId = 'chat.agentHost.ipcLoggingEnabled'; /** - * Configuration key that controls whether the Claude agent provider is registered - * inside the agent host. Read on the workbench side and forwarded to the agent host - * process via the `VSCODE_AGENT_HOST_ENABLE_CLAUDE` environment variable; the agent - * host process must be restarted for changes to take effect. + * Configuration key that holds the absolute path to a locally-installed + * `@anthropic-ai/claude-agent-sdk` package. When non-empty, the Claude agent + * provider is registered inside the agent host and the SDK module is loaded + * via dynamic `import()` from this path. When empty (the default), the + * Claude provider is not registered. The SDK is intentionally not bundled + * with VS Code; users opting into the Claude agent install the SDK + * themselves and point this setting at it. The agent host process must be + * restarted for changes to take effect. */ -export const AgentHostClaudeAgentEnabledSettingId = 'chat.agentHost.claudeAgent.enabled'; +export const AgentHostClaudeAgentSdkPathSettingId = 'chat.agentHost.claudeAgent.path'; /** - * Environment variable that, when set to `'1'`, causes the agent host process to - * register the Claude agent provider. Set by the agent host starters when - * {@link AgentHostClaudeAgentEnabledSettingId} is enabled, and may also be set - * directly by developers as an override. + * Environment variable that holds the absolute path to a locally-installed + * `@anthropic-ai/claude-agent-sdk` package. When set to a non-empty value, + * the agent host process registers the Claude agent provider and loads the + * SDK module from this path. Set by the agent host starters from + * {@link AgentHostClaudeAgentSdkPathSettingId}, and may also be set directly + * by developers as an override. */ -export const AgentHostEnableClaudeEnvVar = 'VSCODE_AGENT_HOST_ENABLE_CLAUDE'; +export const AgentHostClaudeSdkPathEnvVar = 'VSCODE_AGENT_HOST_CLAUDE_SDK_PATH'; /** Result of starting the agent host WebSocket server on-demand. */ export interface IAgentHostSocketInfo { diff --git a/src/vs/platform/agentHost/common/claudeSessionConfigKeys.ts b/src/vs/platform/agentHost/common/claudeSessionConfigKeys.ts new file mode 100644 index 0000000000000..9319fe6a96f9e --- /dev/null +++ b/src/vs/platform/agentHost/common/claudeSessionConfigKeys.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Well-known session-config keys advertised by the agent-host Claude + * provider in its `resolveSessionConfig` schema. + * + * Claude collapses the platform's two-axis approval model + * (`autoApprove` × `mode`) onto a single `permissionMode` axis matching + * the Claude SDK's native `PermissionMode` (see + * `@anthropic-ai/claude-agent-sdk` typings). The four values mirror + * the SDK's enum exactly so that the value flowing back into + * `query({ permissionMode })` requires no translation layer. + * + * The platform `Permissions` key (allow/deny tool lists) is reused + * unchanged from `platformSessionSchema` because the Claude SDK accepts + * `allowedTools` / `disallowedTools` natively. + */ +export const enum ClaudeSessionConfigKey { + /** `'permissionMode'` — Claude SDK approval mode. */ + PermissionMode = 'permissionMode', +} + +/** + * Permission-mode values advertised in the Claude session-config schema. + * Mirror of the SDK's `PermissionMode` union for protocol-stable strings. + */ +export type ClaudePermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'; diff --git a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts index baa6e9ba4bc64..42568627ca603 100644 --- a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts +++ b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts @@ -19,7 +19,7 @@ import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; import { UtilityProcess } from '../../utilityProcess/electron-main/utilityProcess.js'; import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; -import { AgentHostClaudeAgentEnabledSettingId, AgentHostEnableClaudeEnvVar } from '../common/agentService.js'; +import { AgentHostClaudeAgentSdkPathSettingId, AgentHostClaudeSdkPathEnvVar } from '../common/agentService.js'; import { deepClone } from '../../../base/common/objects.js'; export class ElectronAgentHostStarter extends Disposable implements IAgentHostStarter { @@ -65,10 +65,13 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt const shellEnv = await this._resolveShellEnv(); // Gate optional providers via env vars consumed by `agentHostMain.ts`. - // The Claude agent is opt-in: enabled when either the workbench setting is on - // or the env var is already set on the parent process (developer override). - const claudeEnabled = this._configurationService.getValue(AgentHostClaudeAgentEnabledSettingId) - || process.env[AgentHostEnableClaudeEnvVar] === '1'; + // The Claude agent is opt-in: enabled when the user points the SDK path + // setting at a locally-installed `@anthropic-ai/claude-agent-sdk` package, + // or when the env var is already set on the parent process (developer + // override). The SDK itself is intentionally not bundled with VS Code. + const claudeSdkPath = this._configurationService.getValue(AgentHostClaudeAgentSdkPathSettingId) + || process.env[AgentHostClaudeSdkPathEnvVar] + || ''; this.utilityProcess.start({ type: 'agentHost', @@ -85,7 +88,7 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt VSCODE_ESM_ENTRYPOINT: 'vs/platform/agentHost/node/agentHostMain', VSCODE_PIPE_LOGGING: 'true', VSCODE_VERBOSE_LOGGING: 'true', - ...(claudeEnabled ? { [AgentHostEnableClaudeEnvVar]: '1' } : {}), + ...(claudeSdkPath ? { [AgentHostClaudeSdkPathEnvVar]: claudeSdkPath } : {}), } }); diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 972996e689e8f..a46c58ad9d512 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -15,13 +15,14 @@ import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import * as os from 'os'; import * as inspector from 'inspector'; -import { AgentHostEnableClaudeEnvVar, AgentHostIpcChannels, IAgentHostInspectInfo, IAgentHostSocketInfo, IConnectionTrackerService } from '../common/agentService.js'; +import { AgentHostClaudeSdkPathEnvVar, AgentHostIpcChannels, IAgentHostInspectInfo, IAgentHostSocketInfo, IConnectionTrackerService } from '../common/agentService.js'; import { AgentService } from './agentService.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; import { IAgentHostTerminalManager } from './agentHostTerminalManager.js'; import { CopilotAgent } from './copilot/copilotAgent.js'; import { CopilotApiService, ICopilotApiService } from './shared/copilotApiService.js'; import { ClaudeAgent } from './claude/claudeAgent.js'; +import { ClaudeAgentSdkService, IClaudeAgentSdkService } from './claude/claudeAgentSdkService.js'; import { ClaudeProxyService, IClaudeProxyService } from './claude/claudeProxyService.js'; import { ProtocolServerHandler } from './protocolServerHandler.js'; import { WebSocketProtocolServer } from './webSocketTransport.js'; @@ -114,6 +115,8 @@ function startAgentHost(): void { diServices.set(ICopilotApiService, copilotApiService); const claudeProxyService = disposables.add(instantiationService.createInstance(ClaudeProxyService)); diServices.set(IClaudeProxyService, claudeProxyService); + const claudeAgentSdkService = instantiationService.createInstance(ClaudeAgentSdkService); + diServices.set(IClaudeAgentSdkService, claudeAgentSdkService); agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, rootConfigResource); const pluginManager = new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService); diServices.set(IAgentPluginManager, pluginManager); @@ -124,9 +127,11 @@ function startAgentHost(): void { diServices.set(IAgentConfigurationService, agentService.configurationService); agentService.registerProvider(instantiationService.createInstance(CopilotAgent)); // The Claude agent provider is opt-in. Gated on the - // `chat.agentHost.claudeAgent.enabled` workbench setting, forwarded by the - // agent host starters as `VSCODE_AGENT_HOST_ENABLE_CLAUDE`. - if (process.env[AgentHostEnableClaudeEnvVar] === '1') { + // `chat.agentHost.claudeAgent.path` workbench setting being non-empty, + // forwarded by the agent host starters as `VSCODE_AGENT_HOST_CLAUDE_SDK_PATH`. + // The SDK is intentionally not bundled with VS Code; the env var holds the + // absolute path to a locally-installed `@anthropic-ai/claude-agent-sdk` package. + if (process.env[AgentHostClaudeSdkPathEnvVar]) { agentService.registerProvider(instantiationService.createInstance(ClaudeAgent)); } } catch (err) { diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index a04f7da115967..e01083e229b38 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ // Standalone agent host server with WebSocket protocol transport. -// Start with: node out/vs/platform/agentHost/node/agentHostServerMain.js [--port ] [--host ] [--connection-token ] [--connection-token-file ] [--without-connection-token] [--enable-mock-agent] [--enable-claude-agent] [--quiet] [--log ] +// Start with: node out/vs/platform/agentHost/node/agentHostServerMain.js [--port ] [--host ] [--connection-token ] [--connection-token-file ] [--without-connection-token] [--enable-mock-agent] [--claude-sdk-path ] [--quiet] [--log ] import { fileURLToPath } from 'url'; @@ -34,9 +34,10 @@ import { ServiceCollection } from '../../instantiation/common/serviceCollection. import { CopilotAgent } from './copilot/copilotAgent.js'; import { CopilotApiService, ICopilotApiService } from './shared/copilotApiService.js'; import { ClaudeAgent } from './claude/claudeAgent.js'; +import { ClaudeAgentSdkService, IClaudeAgentSdkService } from './claude/claudeAgentSdkService.js'; import { ClaudeProxyService, IClaudeProxyService } from './claude/claudeProxyService.js'; import { AgentService } from './agentService.js'; -import { AgentHostEnableClaudeEnvVar } from '../common/agentService.js'; +import { AgentHostClaudeSdkPathEnvVar } from '../common/agentService.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; import { IAgentHostTerminalManager } from './agentHostTerminalManager.js'; import { WebSocketProtocolServer } from './webSocketTransport.js'; @@ -70,7 +71,8 @@ interface IServerOptions { readonly port: number; readonly host: string | undefined; readonly enableMockAgent: boolean; - readonly enableClaudeAgent: boolean; + /** Absolute path to a locally-installed `@anthropic-ai/claude-agent-sdk` package, or empty to disable the Claude agent. */ + readonly claudeSdkPath: string; readonly quiet: boolean; /** Connection token string, or `undefined` when `--without-connection-token`. */ readonly connectionToken: string | undefined; @@ -84,10 +86,13 @@ function parseServerOptions(): IServerOptions { const hostIdx = argv.indexOf('--host'); const host = hostIdx >= 0 ? argv[hostIdx + 1] : undefined; const enableMockAgent = argv.includes('--enable-mock-agent'); - // Claude agent registration is opt-in: enable via either the CLI flag or the - // shared env var (the env var is what the agent host starters use when the - // `chat.agentHost.claudeAgent.enabled` workbench setting is on). - const enableClaudeAgent = argv.includes('--enable-claude-agent') || process.env[AgentHostEnableClaudeEnvVar] === '1'; + // Claude agent registration is opt-in: enable by passing a path to a + // locally-installed `@anthropic-ai/claude-agent-sdk` package via the CLI + // flag or the shared env var (the env var is what the agent host starters + // use when the `chat.agentHost.claudeAgent.path` workbench setting is set). + // The SDK is intentionally not bundled with VS Code. + const sdkPathIdx = argv.indexOf('--claude-sdk-path'); + const claudeSdkPath = (sdkPathIdx >= 0 ? argv[sdkPathIdx + 1] : process.env[AgentHostClaudeSdkPathEnvVar]) ?? ''; const quiet = argv.includes('--quiet'); // Connection token @@ -130,7 +135,7 @@ function parseServerOptions(): IServerOptions { connectionToken = generateUuid(); } - return { port, host, enableMockAgent, enableClaudeAgent, quiet, connectionToken }; + return { port, host, enableMockAgent, claudeSdkPath, quiet, connectionToken }; } // ---- Main ------------------------------------------------------------------- @@ -206,10 +211,15 @@ async function main(): Promise { diServices.set(ICopilotApiService, copilotApiService); const claudeProxyService = disposables.add(instantiationService.createInstance(ClaudeProxyService)); diServices.set(IClaudeProxyService, claudeProxyService); + const claudeAgentSdkService = instantiationService.createInstance(ClaudeAgentSdkService); + diServices.set(IClaudeAgentSdkService, claudeAgentSdkService); const copilotAgent = disposables.add(instantiationService.createInstance(CopilotAgent)); agentService.registerProvider(copilotAgent); log('CopilotAgent registered'); - if (options.enableClaudeAgent) { + if (options.claudeSdkPath) { + // `ClaudeAgentSdkService` reads `AgentHostClaudeSdkPathEnvVar` directly, + // so make sure it is set even if the path was provided via CLI flag. + process.env[AgentHostClaudeSdkPathEnvVar] = options.claudeSdkPath; const claudeAgent = disposables.add(instantiationService.createInstance(ClaudeAgent)); agentService.registerProvider(claudeAgent); log('ClaudeAgent registered'); diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index 74f2c35bc6724..a85412e8242d8 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -4,19 +4,34 @@ *--------------------------------------------------------------------------------------------*/ import type { CCAModel } from '@vscode/copilot-api'; +import type { Options, SDKSessionInfo, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; +import { rgPath } from '@vscode/ripgrep'; +import { SequencerByKey } from '../../../../base/common/async.js'; +import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter } from '../../../../base/common/event.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; import { IObservable, observableValue } from '../../../../base/common/observable.js'; +import { delimiter, dirname } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { ILogService } from '../../../log/common/log.js'; import { ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentProvider, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata } from '../../common/agentService.js'; +import { createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; +import { ClaudePermissionMode, ClaudeSessionConfigKey } from '../../common/claudeSessionConfigKeys.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +import { AgentProvider, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo } from '../../common/agentService.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ProtectedResourceMetadata, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { CustomizationRef, SessionInputResponseKind, type SessionInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; +import { IAgentHostGitService } from '../agentHostGitService.js'; +import { projectFromCopilotContext } from '../copilot/copilotGitProject.js'; import { ICopilotApiService } from '../shared/copilotApiService.js'; +import { IClaudeAgentSdkService } from './claudeAgentSdkService.js'; +import { ClaudeAgentSession } from './claudeAgentSession.js'; import { tryParseClaudeModelId } from './claudeModelId.js'; +import { resolvePromptToContentBlocks } from './claudePromptResolver.js'; import { IClaudeProxyHandle, IClaudeProxyService } from './claudeProxyService.js'; /** @@ -55,6 +70,34 @@ function toAgentModelInfo(m: CCAModel, provider: AgentProvider): IAgentModelInfo }; } +/** + * Phase 6: in-memory record for a provisional Claude session — one + * created via {@link ClaudeAgent.createSession} that has NOT yet seen + * its first {@link ClaudeAgent.sendMessage}. + * + * Holds: + * - `sessionId` / `sessionUri`: stable identifiers minted at create time. + * - `workingDirectory`: undefined when the caller didn't supply one + * (e.g. legacy `createSession({})` paths). Materialize fails fast if + * it's still missing then; until then a missing `cwd` is harmless + * because no SDK / DB / worktree work has happened. + * - `abortController`: single source of cancellation. Wired into + * {@link Options.abortController} at materialize and aborted by + * {@link ClaudeAgent.shutdown} / {@link ClaudeAgent.disposeSession} + * for provisional records; the materialize path defends against an + * abort racing `await sdk.startup()` (Q8 belt-and-suspenders). + * - `project`: the resolved {@link IAgentSessionProjectInfo} (if any), + * computed once at create time so duplicate `createSession` calls + * for the same URI return identical project metadata. + */ +interface IClaudeProvisionalSession { + readonly sessionId: string; + readonly sessionUri: URI; + readonly workingDirectory: URI | undefined; + readonly abortController: AbortController; + readonly project: IAgentSessionProjectInfo | undefined; +} + /** * Phase 4 skeleton {@link IAgent} provider for the Claude Agent SDK. * @@ -87,10 +130,93 @@ export class ClaudeAgent extends Disposable implements IAgent { private _githubToken: string | undefined; private _proxyHandle: IClaudeProxyHandle | undefined; + /** + * Memoized teardown promise. Set on the first call to {@link shutdown}, + * returned by every subsequent call. Mirrors `CopilotAgent.shutdown` + * at copilotAgent.ts:1246. Phase 5 has no async work so the race + * is benign, but the contract is locked now so Phase 6's real + * async teardown (Query.interrupt(), in-flight metadata writes) + * cannot regress. + */ + private _shutdownPromise: Promise | undefined; + + /** + * Live in-memory session wrappers, keyed by raw session id (not URI). + * Disposing the map disposes every wrapper still in it, so no + * additional teardown is needed in {@link dispose}. {@link createSession} + * is the only writer; {@link disposeSession} and {@link shutdown} + * remove via {@link DisposableMap.deleteAndDispose}, which is idempotent + * if the key has already been removed — the contract that prevents + * double-dispose when the two methods race. + */ + private readonly _sessions = this._register(new DisposableMap()); + + /** + * Phase 6: pending in-memory session records. A `createSession` + * (non-fork) entry lives here until the first {@link sendMessage} + * promotes it to a real {@link ClaudeAgentSession} via + * {@link _materializeProvisional}. Each entry owns an + * {@link AbortController} that is wired into {@link Options.abortController} + * at materialize time, so {@link shutdown} can abort any in-flight + * `await sdk.startup()` cleanly. + * + * Plan section 3.3: provisional state is in-memory only — NO DB write, NO + * SDK contact — until materialize. + */ + private readonly _provisionalSessions = new Map(); + + /** + * Phase 6: fired once per session when {@link _materializeProvisional} + * promotes a provisional record into a real {@link ClaudeAgentSession}. + * The {@link IAgentService} subscribes via the platform contract + * (`agentService.ts:412`) to dispatch the deferred `sessionAdded` + * notification — observers don't see the session in their list until + * persistence has settled. + */ + private readonly _onDidMaterializeSession = this._register(new Emitter()); + readonly onDidMaterializeSession = this._onDidMaterializeSession.event; + + /** + * Per-session-id serializer shared by {@link disposeSession} and + * {@link shutdown}. Phase 5 dispose work is synchronous, so the queued + * tasks resolve immediately and the sequencer is mostly a no-op. The + * routing is locked in now (per plan section 3.3.4 / section 3.3.6) so + * Phase 6's real async teardown (`Query.interrupt()`, in-flight metadata + * writes) inherits per-session serialization for free — a concurrent + * `disposeSession(uri)` already in flight is awaited before + * `shutdown()` reuses the same key. + */ + private readonly _disposeSequencer = new SequencerByKey(); + + /** + * Phase 6: per-session-id serializer for {@link sendMessage}. Held + * across both {@link _materializeProvisional} AND `entry.send()` so + * two concurrent first-message calls on the same session collapse + * into one materialize plus two ordered sends. Separate from + * {@link _disposeSequencer} so a `disposeSession` racing a first send + * still serializes against in-flight teardown without deadlocking + * inside the send sequencer (different key spaces, single + * race-resolution lattice via the underlying `AbortController`). + */ + private readonly _sessionSequencer = new SequencerByKey(); + + /** + * Per-session DB metadata key for the user-picked customization + * directory. Anchors agent customization (instructions, tools, prompts) + * to the user's original folder pick even after Phase 6+ worktree + * materialization moves the working directory. Phase 5 only reads + * this overlay in {@link listSessions}; Phase 6's `sendMessage` + * writes it on first turn and fork's `vacuumInto` carries it forward. + */ + private static readonly _META_CUSTOMIZATION_DIRECTORY = 'claude.customizationDirectory'; + constructor( @ILogService private readonly _logService: ILogService, @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, @IClaudeProxyService private readonly _claudeProxyService: IClaudeProxyService, + @ISessionDataService private readonly _sessionDataService: ISessionDataService, + @IClaudeAgentSdkService private readonly _sdkService: IClaudeAgentSdkService, + @IAgentHostGitService private readonly _gitService: IAgentHostGitService, ) { super(); } @@ -168,12 +294,301 @@ export class ClaudeAgent extends Disposable implements IAgent { // #region Stubs — implemented in later phases - createSession(_config?: IAgentCreateSessionConfig): Promise { - throw new Error('TODO: Phase 5'); + async createSession(config: IAgentCreateSessionConfig = {}): Promise { + if (config.fork) { + // Fork moved to Phase 6.5: requires translating + // `config.fork.turnId` (a protocol turn ID) to an SDK message UUID + // via `sdk.getSessionMessages`. Phase 6's exit criteria explicitly + // scope fork out so the rest of sendMessage can land first. + throw new Error('TODO: Phase 6.5: fork requires message-UUID lookup via sdk.getSessionMessages'); + } + // Non-fork path: provisional. NO subprocess fork, NO worktree, NO DB + // write. Materialization happens lazily in `_materializeProvisional` + // on the first `sendMessage`; AgentService defers `sessionAdded` + // until then. + const sessionId = config.session ? AgentSession.id(config.session) : generateUuid(); + const sessionUri = AgentSession.uri(this.id, sessionId); + + // Idempotency: a duplicate `createSession` for the same URI (already + // materialized OR already provisional) returns the same URI without + // overwriting the existing record. This protects against a workbench + // retry collapsing a real session back into a provisional one. + const existingProvisional = this._provisionalSessions.get(sessionId); + if (existingProvisional) { + return { + session: existingProvisional.sessionUri, + workingDirectory: existingProvisional.workingDirectory, + provisional: true, + ...(existingProvisional.project ? { project: existingProvisional.project } : {}), + }; + } + if (this._sessions.has(sessionId)) { + return { session: sessionUri, workingDirectory: config.workingDirectory }; + } + + // Resolve git project metadata when we have a cwd. Skipped when + // `workingDirectory` is undefined — materialize will require it, + // but a tests-only path (`createSession({})`) without a cwd is + // allowed at Phase 5/6 boundaries; failing fast here would force + // every legacy test to thread a cwd through. + // + // **Deviation from plan section 3.3 (deviation D1, ratified by review).** + // The plan called for `if (!config.workingDirectory) { throw ... }` + // at create time. We accept cwd-less calls and defer the throw to + // `_materializeProvisional` instead. Trade-off: a programmer error + // (forgetting to thread cwd) surfaces at first `sendMessage` + // rather than `createSession`. This is acceptable because: + // (a) the agent host's own callers always supply cwd via folder + // pick (`agentSideEffects.ts`) — the cwd-less path only exists + // for unit tests asserting protocol-only behavior; and + // (b) materialize requires cwd anyway, so the failure mode is + // bounded and visible (no silent invalid sessions). + const project = config.workingDirectory + ? await projectFromCopilotContext({ cwd: config.workingDirectory.fsPath }, this._gitService) + : undefined; + + this._provisionalSessions.set(sessionId, { + sessionId, + sessionUri, + workingDirectory: config.workingDirectory, + abortController: new AbortController(), + project, + }); + + return { + session: sessionUri, + workingDirectory: config.workingDirectory, + provisional: true, + ...(project ? { project } : {}), + }; } - disposeSession(_session: URI): Promise { - throw new Error('TODO: Phase 5'); + /** + * Factory hook for the per-session wrapper. Tests override this to + * inject a recording subclass and observe dispose order/count without + * monkey-patching the live `_sessions` map. Mirrors CopilotAgent's + * `_createCopilotClient` pattern (`copilotAgent.ts:286`). + */ + protected _createSessionWrapper( + sessionId: string, + sessionUri: URI, + workingDirectory: URI | undefined, + warm: import('@anthropic-ai/claude-agent-sdk').WarmQuery, + abortController: AbortController, + ): ClaudeAgentSession { + return new ClaudeAgentSession( + sessionId, + sessionUri, + workingDirectory, + warm, + abortController, + this._onDidSessionProgress, + this._logService, + ); + } + + /** + * Promote a {@link IClaudeProvisionalSession} into a real + * {@link ClaudeAgentSession}. Called from {@link sendMessage} inside + * the {@link _sessionSequencer.queue} block, so concurrent first + * sends serialize naturally — exactly one materialize per session. + * + * Plan section 3.4. Failure modes: + * - Missing provisional record → programmer error, throws. + * - Missing proxy handle → caller forgot {@link authenticate}, throws. + * - Aborted before SDK init returns → dispose the {@link WarmQuery} + * and throw {@link CancellationError}. + * - Customization-directory persistence failure → fatal: dispose the + * wrapper (aborts the SDK subprocess), drop the provisional record, + * re-throw. Avoids silent half-persisted state. + */ + private async _materializeProvisional(sessionId: string): Promise { + const provisional = this._provisionalSessions.get(sessionId); + if (!provisional) { + throw new Error(`Cannot materialize unknown provisional session: ${sessionId}`); + } + if (!provisional.workingDirectory) { + throw new Error(`Cannot materialize Claude session ${sessionId}: workingDirectory is required`); + } + const proxyHandle = this._proxyHandle; + if (!proxyHandle) { + throw new Error('Claude proxy is not running; agent must be authenticated first'); + } + + const subprocessEnv = this._buildSubprocessEnv(); + // Settings env: forwarded to the Claude subprocess via the SDK's + // `Options.settings.env` channel (separate from `Options.env` which + // is the spawn env). PATH composition uses `delimiter` (`:` or `;`) + // so Windows agent hosts don't corrupt PATH on subprocess fork. + // In packaged builds @vscode/ripgrep lives inside node_modules.asar; the + // rg binary itself is unpacked next door, so rewrite the path before + // putting it on PATH (matches `copilotAgent.ts` and the workbench + // search engine helpers). + const rgDiskPath = rgPath.replace(/\bnode_modules\.asar\b/, 'node_modules.asar.unpacked'); + const settingsEnv: Record = { + ANTHROPIC_BASE_URL: proxyHandle.baseUrl, + ANTHROPIC_AUTH_TOKEN: `${proxyHandle.nonce}.${sessionId}`, + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + USE_BUILTIN_RIPGREP: '0', + PATH: `${dirname(rgDiskPath)}${delimiter}${process.env.PATH ?? ''}`, + }; + + const options: Options = { + cwd: provisional.workingDirectory.fsPath, + executable: process.execPath as 'node', + env: subprocessEnv, + abortController: provisional.abortController, + allowDangerouslySkipPermissions: true, + canUseTool: async (_name, _input) => ({ + behavior: 'deny', + message: 'Tools are not yet enabled for this session (Phase 6).', + }), + disallowedTools: ['WebSearch'], + includeHookEvents: true, + includePartialMessages: true, + permissionMode: 'default', + sessionId, + settingSources: ['user', 'project', 'local'], + settings: { env: settingsEnv }, + systemPrompt: { type: 'preset', preset: 'claude_code' }, + stderr: data => this._logService.error(`[Claude SDK stderr] ${data}`), + }; + + const warm = await this._sdkService.startup({ options }); + + // Q8 belt-and-suspenders: the SDK's comment guarantees abort cleanup + // (sdk.d.ts:982), but if `startup()` resolved despite a racing abort, + // dispose the WarmQuery and surface cancellation. The agent has been + // shutting down while we awaited; do NOT materialize. + if (provisional.abortController.signal.aborted) { + await warm[Symbol.asyncDispose](); + throw new CancellationError(); + } + + const session = this._createSessionWrapper( + sessionId, + provisional.sessionUri, + provisional.workingDirectory, + warm, + provisional.abortController, + ); + + // Persist customization-directory metadata BEFORE firing the + // materialize event — see plan section 3.4 ordering rationale. + try { + await this._writeCustomizationDirectory(provisional.sessionUri, provisional.workingDirectory); + } catch (err) { + session.dispose(); + this._provisionalSessions.delete(sessionId); + this._logService.error(`[Claude] Failed to persist customization directory; aborting materialize`, err); + throw err; + } + + // Final pre-commit abort gate. The first abort gate above only + // catches an abort that lands while `await sdk.startup()` was in + // flight; `_writeCustomizationDirectory` is a SECOND async + // boundary where a racing `disposeSession` (which does not await + // the materialize via `_disposeSequencer` because send and dispose + // use different sequencers — plan section 3.8 / section 6) can fire between + // the SDK init and the `_sessions.set(...)` commit. Without this + // gate, the dispose returns successfully, the provisional record + // is removed, and the materialize still completes — leaking a + // WarmQuery subprocess into `_sessions` that nothing else + // references. Council-review C1. + if (provisional.abortController.signal.aborted) { + session.dispose(); + this._provisionalSessions.delete(sessionId); + throw new CancellationError(); + } + + this._sessions.set(sessionId, session); + this._provisionalSessions.delete(sessionId); + + this._onDidMaterializeSession.fire({ + session: provisional.sessionUri, + workingDirectory: provisional.workingDirectory, + project: provisional.project, + }); + + return session; + } + + /** + * Build the {@link Options.env} payload for the Claude subprocess. + * + * The agent host runs in an Electron utility process; the spawn env + * inherits the parent's env which contains `NODE_OPTIONS`, + * `ELECTRON_*`, and `VSCODE_*` variables that break the Claude + * subprocess (it's a plain Node script driven by Electron's + * `process.execPath` + `ELECTRON_RUN_AS_NODE`). Strip them via + * {@link Options.env} `undefined` semantics (sdk.d.ts:1075-1078: + * "Set a key to `undefined` to remove an inherited variable"). + * + * Mirror of CopilotAgent's strip pattern at copilotAgent.ts:434-450. + */ + private _buildSubprocessEnv(): Record { + const env: Record = { + ELECTRON_RUN_AS_NODE: '1', + NODE_OPTIONS: undefined, + ANTHROPIC_API_KEY: undefined, + }; + for (const key of Object.keys(process.env)) { + if (key === 'ELECTRON_RUN_AS_NODE') { continue; } + if (key.startsWith('VSCODE_') || key.startsWith('ELECTRON_')) { + env[key] = undefined; + } + } + return env; + } + + /** + * Persist the user's customization-directory pick to the per-session + * DB so {@link listSessions} can surface it (and Phase 6+ worktree + * materialization can still find the original folder). Mirrors + * CopilotAgent's `_storeSessionMetadata` pattern. + */ + private async _writeCustomizationDirectory(session: URI, workingDirectory: URI): Promise { + const dbRef = this._sessionDataService.openDatabase(session); + try { + await dbRef.object.setMetadata( + ClaudeAgent._META_CUSTOMIZATION_DIRECTORY, + workingDirectory.toString(), + ); + } finally { + dbRef.dispose(); + } + } + + disposeSession(session: URI): Promise { + // Routed through {@link _disposeSequencer} so a concurrent + // {@link shutdown} already serializing teardown for this same + // session id awaits this work first (and vice versa). Phase 6 + // adds a provisional branch: when the session has not yet been + // materialized, abort the controller (unblocks any racing + // `await sdk.startup()`) and drop the record. No SDK contact, + // no DB write — symmetric with `createSession`. + const sessionId = AgentSession.id(session); + return this._disposeSequencer.queue(sessionId, async () => { + const provisional = this._provisionalSessions.get(sessionId); + if (provisional) { + provisional.abortController.abort(); + this._provisionalSessions.delete(sessionId); + return; + } + this._sessions.deleteAndDispose(sessionId); + }); + } + + /** + * Test-only accessor for the materialized {@link ClaudeAgentSession}. + * Phase 6 section 5.1 Test 10 needs to inspect `_isResumed` directly because + * Phase 6 has no teardown+recreate flow yet to observe its effect + * (the flag drives `Options.resume = sessionId` in Phase 7+). Marked + * `ForTesting` so the production surface stays unaware of its + * existence; the protocol surface (`IAgent`) does not include it. + */ + getSessionForTesting(session: URI): ClaudeAgentSession | undefined { + return this._sessions.get(AgentSession.id(session)); } /** @@ -181,27 +596,195 @@ export class ClaudeAgent extends Disposable implements IAgent { * Phase 13; the bare method shape is required by {@link IAgent}. */ getSessionMessages(_session: URI): Promise { - throw new Error('TODO: Phase 5'); + // Phase 5 has nothing to reconstruct: there is no SDK Query + // running yet and no event log on disk has been read. The agent + // service surfaces in-memory provisional turns until Phase 13 + // implements transcript reconstruction from the SDK event log. + // A fresh array per call avoids leaking mutations across + // subscribers. + return Promise.resolve([]); } - listSessions(): Promise { - throw new Error('TODO: Phase 5'); + async listSessions(): Promise { + // Plan section 3.3.2: SDK is the source of truth; the per-session DB + // is a pure overlay/cache for Claude-namespaced fields like + // `customizationDirectory`. We deliberately do NOT filter + // entries that lack a DB — external Claude Code CLI sessions + // have no DB and must still surface (Phase-5 exit criterion). + // + // Each per-session overlay read is independently try/caught so a + // single corrupt DB cannot poison the wider listing. CopilotAgent's + // `Promise.all`-with-throwing-mapper pattern at copilotAgent.ts:519 + // has a latent bug; we follow AgentService.listSessions's resilient + // pattern (`agentService.ts:188-204`) instead. + // + // `AgentService.listSessions` fans out across all providers via + // `Promise.all` (agentService.ts:202-204). If our SDK dynamic + // import fails (corrupt install, missing optional dep) and we let + // it reject, *every* provider's session list disappears — the + // sibling Copilot provider gets nuked too. Catch and log instead. + let sdkEntries: readonly SDKSessionInfo[]; + try { + sdkEntries = await this._sdkService.listSessions(); + } catch (err) { + this._logService.warn('[Claude] SDK listSessions failed; surfacing empty list', err); + return []; + } + return Promise.all(sdkEntries.map(async entry => { + try { + const sessionUri = AgentSession.uri(this.id, entry.sessionId); + const dbRef = await this._sessionDataService.tryOpenDatabase(sessionUri); + if (dbRef) { + try { + const raw = await dbRef.object.getMetadata(ClaudeAgent._META_CUSTOMIZATION_DIRECTORY); + return this._toAgentSessionMetadata(entry, { + customizationDirectory: raw ? URI.parse(raw) : undefined, + }); + } finally { + dbRef.dispose(); + } + } + } catch (err) { + this._logService.warn(`[Claude] Overlay read failed for session ${entry.sessionId}`, err); + } + // External session, or DB read failed: surface what the SDK gave us. + return this._toAgentSessionMetadata(entry, {}); + })); + } + + private _toAgentSessionMetadata(entry: SDKSessionInfo, overlay: { customizationDirectory?: URI }): IAgentSessionMetadata { + return { + session: AgentSession.uri(this.id, entry.sessionId), + startTime: entry.createdAt ?? entry.lastModified, + modifiedTime: entry.lastModified, + summary: entry.customTitle ?? entry.summary, + workingDirectory: entry.cwd ? URI.file(entry.cwd) : undefined, + customizationDirectory: overlay.customizationDirectory, + }; } resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise { - throw new Error('TODO: Phase 5'); + // Decision B5 (plan section 3.3.5): Claude collapses the platform's + // `autoApprove` × `mode` two-axis approval surface onto a single + // `permissionMode` axis matching the SDK's native enum. The + // platform `Permissions` key is reused unchanged because the + // Claude SDK accepts `allowedTools` / `disallowedTools` + // natively. Skipped: AutoApprove, Mode, Isolation, Branch, + // BranchNameHint — workbench pickers key off the property names + // to decide what to render, so omitting these intentionally + // suppresses the default mode/branch UI for Claude sessions. + const sessionSchema = createSchema({ + [ClaudeSessionConfigKey.PermissionMode]: schemaProperty({ + type: 'string', + title: localize('claude.sessionConfig.permissionMode', "Approvals"), + description: localize('claude.sessionConfig.permissionModeDescription', "How Claude handles tool approvals."), + enum: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], + enumLabels: [ + localize('claude.sessionConfig.permissionMode.default', "Ask Each Time"), + localize('claude.sessionConfig.permissionMode.acceptEdits', "Auto-Approve Edits"), + localize('claude.sessionConfig.permissionMode.bypassPermissions', "Bypass Approvals"), + localize('claude.sessionConfig.permissionMode.plan', "Plan Only (Read-Only)"), + ], + enumDescriptions: [ + localize('claude.sessionConfig.permissionMode.defaultDescription', "Prompt for every tool call."), + localize('claude.sessionConfig.permissionMode.acceptEditsDescription', "Auto-approve file edits; prompt for shell and other tools."), + localize('claude.sessionConfig.permissionMode.bypassPermissionsDescription', "Auto-approve every tool call."), + localize('claude.sessionConfig.permissionMode.planDescription', "Read-only research mode; no tool calls executed."), + ], + default: 'default', + sessionMutable: true, + }), + [SessionConfigKey.Permissions]: platformSessionSchema.definition[SessionConfigKey.Permissions], + }); + + const values = sessionSchema.validateOrDefault(_params.config, { + [ClaudeSessionConfigKey.PermissionMode]: 'default' satisfies ClaudePermissionMode, + // Permissions intentionally omitted from defaults — leave + // unset so auto-approval falls through to the host-level + // default, materializing on the session only once the user + // approves a tool "in this Session". + }); + + return Promise.resolve({ + schema: sessionSchema.toProtocol(), + values, + }); } sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { - throw new Error('TODO: Phase 5'); + // Plan section 3.3.5: Claude's only schema property is the + // `permissionMode` static enum, so dynamic completion is + // definitionally empty in Phase 5. Branch completion lands in + // Phase 6 once worktree extraction (section 8) is settled. + return Promise.resolve({ items: [] }); } shutdown(): Promise { - throw new Error('TODO: Phase 5'); + // Phase 6: drain provisional sessions FIRST so any in-flight + // `await sdk.startup()` (kicked off by a racing `sendMessage`) + // observes the abort and unwinds. Each provisional record's + // AbortController is wired into Options.abortController at + // materialize time, so aborting here flips the same signal the + // SDK is racing on. + // + // Then drain the materialized sessions through the existing + // per-session {@link _disposeSequencer} routing — that path + // inherits Phase 6's real async teardown (`Query.interrupt()`, + // in-flight metadata writes) once those land. + // + // The promise is memoized so concurrent callers share a single + // drain pass — see `_shutdownPromise` JSDoc. + // NOTE: declared sync (returns Promise) rather than async + // so that re-entrant calls return the cached promise *identity*, + // not a fresh outer-async wrapper around it. + return this._shutdownPromise ??= (async () => { + for (const provisional of this._provisionalSessions.values()) { + provisional.abortController.abort(); + } + this._provisionalSessions.clear(); + + const sessionIds = [...this._sessions.keys()]; + await Promise.all(sessionIds.map(sessionId => + this._disposeSequencer.queue(sessionId, async () => { + this._sessions.deleteAndDispose(sessionId); + }) + )); + })(); } - sendMessage(_session: URI, _prompt: string, _attachments?: IAgentAttachment[], _turnId?: string): Promise { - throw new Error('TODO: Phase 6'); + async sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[], turnId?: string): Promise { + // Plan section 3.8. The sequencer scope holds across BOTH materialize + // and `entry.send` so two concurrent first-message calls on the + // same session collapse into one materialize plus two ordered + // sends. A `disposeSession` racing a first send reaches its own + // dispose-sequencer eventually but the in-flight materialize + // completes first. + const sessionId = AgentSession.id(session); + // `IAgent.sendMessage` declares `turnId?` (agentService.ts:424) but + // every production caller in `AgentSideEffects` supplies one. Generate + // a fallback so the session-side `QueuedRequest.turnId: string` + // invariant holds even if a hypothetical caller forgets it. + const effectiveTurnId = turnId ?? generateUuid(); + return this._sessionSequencer.queue(sessionId, async () => { + let entry = this._sessions.get(sessionId); + if (!entry) { + if (this._provisionalSessions.has(sessionId)) { + entry = await this._materializeProvisional(sessionId); + } else { + throw new Error(`Cannot send to unknown session: ${sessionId}`); + } + } + + const contentBlocks = resolvePromptToContentBlocks(prompt, attachments); + const sdkPrompt: SDKUserMessage = { + type: 'user', + message: { role: 'user', content: contentBlocks }, + session_id: sessionId, + parent_tool_use_id: null, + }; + + await entry.send(sdkPrompt, effectiveTurnId); + }); } respondToPermissionRequest(_requestId: string, _approved: boolean): void { @@ -212,11 +795,13 @@ export class ClaudeAgent extends Disposable implements IAgent { throw new Error('TODO: Phase 7'); } - abortSession(_session: URI): Promise { + async abortSession(_session: URI): Promise { + // `async` for the same reason as `sendMessage` — abort flows through + // `.catch()` chains in the agent service. throw new Error('TODO: Phase 9'); } - changeModel(_session: URI, _model: ModelSelection): Promise { + async changeModel(_session: URI, _model: ModelSelection): Promise { throw new Error('TODO: Phase 9'); } @@ -239,17 +824,39 @@ export class ClaudeAgent extends Disposable implements IAgent { // #endregion override dispose(): void { - // Phase 6+ INVARIANT: SDK subprocess(es) MUST be killed BEFORE the - // proxy handle is disposed. After dispose the proxy may rebind on - // a different port and the subprocess would silently lose its - // endpoint. See `IClaudeProxyHandle` doc in `claudeProxyService.ts`. - // In Phase 4 there are no subprocesses, so this ordering is moot — - // but the comment is mandatory so future contributors don't break - // it when they wire the SDK in. + // Phase 6+ INVARIANT: SDK Query subprocesses (owned by individual + // ClaudeAgentSession wrappers) MUST die BEFORE the proxy handle + // is disposed. After proxy disposal the proxy may rebind on a + // different port and a still-running subprocess would silently + // lose its endpoint. See `IClaudeProxyHandle` doc in + // `claudeProxyService.ts`. + // + // Step 1: abort every provisional AbortController. These are + // the same controllers wired into `Options.abortController` at + // materialize time (sdk.d.ts:982), so any in-flight + // `await sdk.startup()` will reject and any sequencer-queued + // `_materializeProvisional` continuation will trip its + // post-startup or post-customization-write abort gates, + // disposing the WarmQuery without ever reaching + // `_sessions.set(...)`. Without this step, dispose during a + // concurrent first `sendMessage` could orphan a WarmQuery + // subprocess. (Copilot reviewer: dispose lifecycle.) + // + // Step 2: `super.dispose()` synchronously disposes the + // `_sessions` DisposableMap, firing each session wrapper's + // `dispose()` (which interrupts/asyncDisposes its WarmQuery). + // + // Step 3: only then release the proxy handle, preserving the + // wrapper-before-proxy ordering invariant. This is locked by + // test "dispose disposes the proxy handle and is idempotent". + for (const provisional of this._provisionalSessions.values()) { + provisional.abortController.abort(); + } + this._provisionalSessions.clear(); + super.dispose(); this._proxyHandle?.dispose(); this._proxyHandle = undefined; this._githubToken = undefined; this._models.set([], undefined); - super.dispose(); } } diff --git a/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts b/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts new file mode 100644 index 0000000000000..d3838335f6c32 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { ListSessionsOptions, Options, SDKSessionInfo, WarmQuery } from '@anthropic-ai/claude-agent-sdk'; +import * as fs from 'fs'; +import { pathToFileURL } from 'url'; +import { join, resolve } from '../../../../base/common/path.js'; +import { createDecorator } from '../../../instantiation/common/instantiation.js'; +import { ILogService } from '../../../log/common/log.js'; +import { AgentHostClaudeSdkPathEnvVar } from '../../common/agentService.js'; + +export const IClaudeAgentSdkService = createDecorator('claudeAgentSdkService'); + +/** + * Lazy wrapper over `@anthropic-ai/claude-agent-sdk` for the agent host + * Claude provider. The interface grows phase-by-phase; Phase 5 introduces + * the decorator so {@link import('./claudeAgent.js').ClaudeAgent} can take + * it as a constructor dependency. Phase 6 adds {@link startup} for + * materialization. Method surfaces are added in subsequent slices alongside + * the tests that exercise them. + */ +export interface IClaudeAgentSdkService { + readonly _serviceBrand: undefined; + + /** + * Enumerates persisted Claude sessions surfaced by the SDK's filesystem + * scan. Phase 5 mirrors `IAgent.listSessions()` (no `dir` parameter): + * the host translates this internally to `sdk.listSessions(undefined)`. + * + * Failures (corrupt module, postinstall mishap) reject with the SDK + * loader's diagnostic. Callers MUST tolerate rejection without + * collapsing the wider listing pipeline. + */ + listSessions(): Promise; + + /** + * Pre-warms the SDK subprocess and runs the init handshake. Returns + * a {@link WarmQuery} whose `.query(promptIterable)` binds the + * prompt iterable and returns a streaming `Query`. Aborting + * `options.abortController` either rejects this promise (if init is + * in flight) or causes the resulting Query to clean up resources + * (sdk.d.ts section `startup`). + * + * Phase 6 calls this from {@link ClaudeAgent._materializeProvisional} + * on the first `sendMessage`. Firing `onDidMaterializeSession` is + * deliberately deferred until after the await resolves so AgentService + * can atomically dispatch the deferred `sessionAdded` notification. + */ + startup(params: { options: Options; initializeTimeoutMs?: number }): Promise; +} + +/** + * Narrowed structural slice of `@anthropic-ai/claude-agent-sdk` covering + * exactly the bindings the agent host pulls from the SDK. Production + * `import()` returns the full module which is structurally assignable to + * this interface; tests subclass {@link ClaudeAgentSdkService} and + * override {@link ClaudeAgentSdkService._loadSdk} to fault or stub these + * bindings without having to name every export of the SDK module. + */ +export interface IClaudeSdkBindings { + listSessions(options?: ListSessionsOptions): Promise; + startup(params: { options: Options; initializeTimeoutMs?: number }): Promise; +} + +/** + * Production implementation. The SDK module is loaded lazily via dynamic + * `import()` because it pulls in non-trivial deps that aren't relevant + * unless the user has opted into the Claude agent. + * + * The loader's caching / log-once-on-failure semantics are locked by the + * dedicated test in {@link import('../../test/node/claudeAgent.test.ts')}, + * which subclasses this and overrides {@link _loadSdk} to fault on demand. + * That's why {@link _loadSdk} is `protected` rather than `private`. + */ +export class ClaudeAgentSdkService implements IClaudeAgentSdkService { + declare readonly _serviceBrand: undefined; + + /** + * Cached resolved bindings. We deliberately cache the *resolved* value, + * not the in-flight promise — if a transient `import()` failure recovers + * (e.g. user fixes a broken `node_modules`), the next call retries. + * Mirrors the convention in `agentHostTerminalManager.ts` for `node-pty`. + */ + private _sdkModule: IClaudeSdkBindings | undefined; + + /** + * Latched once we've logged a load failure, so a corrupt postinstall + * doesn't flood `error` events on every `listSessions()` call (each + * workbench refresh and session-list rerender hits this path). + */ + private _firstLoadFailureLogged = false; + + constructor( + @ILogService private readonly _logService: ILogService, + ) { } + + async listSessions(): Promise { + const sdk = await this._getSdk(); + return sdk.listSessions(undefined); + } + + async startup(params: { options: Options; initializeTimeoutMs?: number }): Promise { + const sdk = await this._getSdk(); + return sdk.startup(params); + } + + private async _getSdk(): Promise { + if (this._sdkModule) { + return this._sdkModule; + } + try { + this._sdkModule = await this._loadSdk(); + return this._sdkModule; + } catch (err) { + if (!this._firstLoadFailureLogged) { + this._firstLoadFailureLogged = true; + this._logService.error('[Claude] Failed to load @anthropic-ai/claude-agent-sdk', err); + } + throw err; + } + } + + protected async _loadSdk(): Promise { + // The SDK is intentionally not bundled with VS Code. The user supplies an + // absolute path to a locally-installed `@anthropic-ai/claude-agent-sdk` + // package via the `chat.agentHost.claudeAgent.path` setting, which is + // forwarded to this process as `AgentHostClaudeSdkPathEnvVar`. Convert + // to a `file://` URL so dynamic `import()` accepts paths with spaces and + // works on Windows. + const sdkPath = process.env[AgentHostClaudeSdkPathEnvVar]; + if (!sdkPath) { + throw new Error(`Cannot load @anthropic-ai/claude-agent-sdk: ${AgentHostClaudeSdkPathEnvVar} is not set. Set the 'chat.agentHost.claudeAgent.path' setting to a locally-installed SDK package.`); + } + // Node ESM rejects directory imports, so if the user pointed at the + // package directory, resolve its `exports['.']` / `main` entry first. + let entry = sdkPath; + if (fs.statSync(sdkPath).isDirectory()) { + const pkgJson = JSON.parse(fs.readFileSync(join(sdkPath, 'package.json'), 'utf8')); + const mainEntry = pkgJson.exports?.['.']?.default + ?? pkgJson.exports?.['.']?.import + ?? pkgJson.main + ?? 'index.js'; + entry = resolve(sdkPath, mainEntry); + } + return import(pathToFileURL(entry).href); + } +} diff --git a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts new file mode 100644 index 0000000000000..92939db34ed2b --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts @@ -0,0 +1,268 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Query, SDKMessage, SDKUserMessage, WarmQuery } from '@anthropic-ai/claude-agent-sdk'; +import { DeferredPromise } from '../../../../base/common/async.js'; +import { CancellationError } from '../../../../base/common/errors.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ILogService } from '../../../log/common/log.js'; +import { AgentSignal } from '../../common/agentService.js'; +import { IClaudeMapperState, mapSDKMessageToAgentSignals } from './claudeMapSessionEvents.js'; + +/** + * One in-flight {@link send} request. Length of {@link ClaudeAgentSession._inFlightRequests} + * is at most 1 in Phase 6 thanks to the per-session sequencer in `ClaudeAgent`, + * but the queue shape is preserved so Phase 7+ tools (intra-turn waits) + * can extend without reshaping the loop. + */ +interface IQueuedRequest { + readonly prompt: SDKUserMessage; + readonly deferred: DeferredPromise; + /** + * Required (non-optional). The agent's `sendMessage` accepts + * `turnId?: string` (`agentService.ts:424`); `ClaudeAgent.sendMessage` + * generates a UUID if absent before forwarding here, so by the time + * a request reaches the session it always carries a turn id. The + * mapper depends on this for `SessionAction.turnId` population. + */ + readonly turnId: string; +} + +/** + * Per-session SDK Query owner. + * + * Holds the {@link WarmQuery}, the bound {@link Query}, the + * per-session {@link AbortController}, the prompt iterable, and the + * in-flight request queue. Disposing the session aborts the controller + * which (per `sdk.d.ts:982`) terminates the SDK subprocess; the + * WarmQuery is also explicitly disposed so any pending native handles + * release. + * + * Plan section 3.5. Phase 6 deliberately keeps the message → signal mapping + * out of this class — see `claudeMapSessionEvents.ts` (added Cycle 6). + * Cycle 3 lands the bare consumer loop: drain the SDK iterator, + * complete the in-flight deferred on `result`. Subsequent cycles add + * the mapper call and the `_isResumed` / fatal-error / cancellation + * branches. + */ +export class ClaudeAgentSession extends Disposable { + + /** + * SDK Query handle. Bound on the first {@link send} call (so every + * subsequent send pushes onto the same prompt iterable rather than + * spawning a new query). Phase 6 binds exactly once. + */ + private _query: Query | undefined; + + /** + * Wakes the prompt iterable's `next()` when a new prompt arrives or + * on abort. Replaced on every consumed prompt. + */ + private _pendingPromptDeferred = new DeferredPromise(); + + /** + * FIFO of in-flight requests. Length at most 1 in Phase 6 due to the + * agent-side `_sessionSequencer`. The mapper reads + * `_inFlightRequests[0]?.turnId` to populate `SessionAction.turnId` + * — only valid because of the single-in-flight invariant. + */ + private _inFlightRequests: IQueuedRequest[] = []; + + /** + * Prompts pushed by {@link send}, drained by the prompt iterable. + * Separate from {@link _inFlightRequests} because the iterable's + * consumer loop pops from here while the result-completion loop + * pops from the in-flight list. + */ + private _queuedPrompts: SDKUserMessage[] = []; + + /** + * Mutable state threaded into {@link mapSDKMessageToAgentSignals}. + * Lives on the session (not the mapper module) so that concurrent + * sessions don't cross-contaminate part-id allocations. + */ + private readonly _mapperState: IClaudeMapperState = { currentBlockParts: new Map() }; + + /** + * Flips to `true` on the first `system:init` SDK message. Phase 7+ + * teardown+recreate flows pass `Options.resume = sessionId` to the + * SDK on a recreated session iff `_isResumed === true`, signalling + * the SDK to reuse the existing transcript. Phase 6 only sets the + * flag — no recreate flow exists yet. + */ + private _isResumed = false; + + get isResumed(): boolean { + return this._isResumed; + } + + /** + * Latched once {@link _processMessages} terminates with an error + * (cancellation, transport failure, malformed SDK output). Every + * pending in-flight deferred is rejected with the same error, and + * subsequent {@link send} calls fast-fail with this latched value + * instead of parking on a dead query. Phase 7+ teardown+recreate + * flows clear this when the session is re-bound. + */ + private _fatalError: Error | undefined; + + constructor( + readonly sessionId: string, + readonly sessionUri: URI, + readonly workingDirectory: URI | undefined, + private readonly _warm: WarmQuery, + private readonly _abortController: AbortController, + private readonly _onDidSessionProgress: Emitter, + private readonly _logService: ILogService, + ) { + super(); + // Dispose chain → abort → SDK cleanup (sdk.d.ts:982). + this._register(toDisposable(() => this._abortController.abort())); + // Wake any parked prompt iterator so it can return `{ done: true }`. + this._abortController.signal.addEventListener('abort', () => { + this._pendingPromptDeferred.complete(); + }, { once: true }); + // The WarmQuery owns disposable resources (subprocess handle, etc.). + // The dispose path is async but VS Code's lifecycle is sync — fire + // and forget; log failures so a leaked handle surfaces. The SDK + // types `Symbol.asyncDispose()` as `PromiseLike`, so wrap in + // `Promise.resolve` to get `.catch`. + this._register(toDisposable(() => { + void Promise.resolve(this._warm[Symbol.asyncDispose]()).catch((err: unknown) => + this._logService.warn(`[ClaudeAgentSession] WarmQuery dispose failed: ${err}`)); + })); + } + + /** + * Push a prompt onto the queue and await the turn's completion (the + * `result` SDKMessage). The first call also binds the prompt iterable + * to the WarmQuery and kicks off the consumer loop. + */ + async send(prompt: SDKUserMessage, turnId: string): Promise { + if (this._fatalError) { + // Fast-fail: a previous turn crashed `_processMessages`. The + // query and prompt iterable are already torn down, so a new + // `send` here would push onto a dead pipe and park forever. + throw this._fatalError; + } + if (this._abortController.signal.aborted) { + throw new CancellationError(); + } + if (!this._query) { + this._query = this._warm.query(this._createPromptIterable()); + // Fire-and-forget: errors propagate via the in-flight deferred + // (rejected by `_processMessages`'s catch latch) and are + // re-logged here as a belt-and-suspenders for the no-inflight + // case (e.g. a stream that errors before the first send). + void this._processMessages().catch(err => + this._logService.error(`[ClaudeAgentSession] _processMessages crashed: ${err}`)); + } + const deferred = new DeferredPromise(); + this._inFlightRequests.push({ prompt, deferred, turnId }); + this._queuedPrompts.push(prompt); + this._pendingPromptDeferred.complete(); + return deferred.p; + } + + /** + * Build the prompt iterable bound to {@link WarmQuery.query}. + * Each `next()` parks on {@link _pendingPromptDeferred} until either + * a prompt arrives ({@link send}) or the controller aborts. + */ + private _createPromptIterable(): AsyncIterable { + return { + [Symbol.asyncIterator]: () => ({ + next: async () => { + while (this._queuedPrompts.length === 0) { + if (this._abortController.signal.aborted) { + return { done: true, value: undefined }; + } + await this._pendingPromptDeferred.p; + this._pendingPromptDeferred = new DeferredPromise(); + } + return { done: false, value: this._queuedPrompts.shift()! }; + }, + }), + }; + } + + /** + * Consumer loop. Drains the SDK iterator, calls the pure mapper to + * convert each {@link SDKMessage} into {@link AgentSignal}s, fires + * them through `_onDidSessionProgress`, and completes the in-flight + * deferred on `result`. The mapper is called inside a try/catch so a + * single malformed SDK message can't kill the turn. + * + * On any uncaught error (cancellation, transport failure, or the + * post-loop "stream ended without result" guard) the catch block + * latches {@link _fatalError}, rejects every pending in-flight + * deferred with the same error, and rethrows so the void wrapper in + * {@link send} logs it. The latch ensures subsequent {@link send} + * calls fast-fail instead of parking on a dead query. + */ + private async _processMessages(): Promise { + const query = this._query; + if (!query) { + throw new Error('ClaudeAgentSession._processMessages called before query was bound'); + } + try { + for await (const message of query) { + if (this._abortController.signal.aborted) { + throw new CancellationError(); + } + if (message.type === 'system' && message.subtype === 'init' && !this._isResumed) { + this._isResumed = true; + } + // Mapper needs the current turn's `turnId`. Phase 6's + // per-session sequencer keeps `_inFlightRequests.length <= 1` + // while a turn is streaming, so the head element is the + // active turn. Skip mapping if no turn is in flight (e.g. + // the SDK emits a stray pre-prompt system message). + const turnId = this._inFlightRequests[0]?.turnId; + if (turnId !== undefined) { + try { + const signals = mapSDKMessageToAgentSignals( + message, + this.sessionUri, + turnId, + this._mapperState, + this._logService, + ); + for (const signal of signals) { + this._onDidSessionProgress.fire(signal); + } + } catch (mapperErr) { + this._logService.warn(`[ClaudeAgentSession] mapper threw, skipping message: ${mapperErr}`); + } + } + if (message.type === 'result') { + const completed = this._inFlightRequests.shift(); + completed?.deferred.complete(); + } + } + // Distinguish a cancelled stream (aborted controller drained + // the iterator cleanly) from a truly anomalous end-of-stream. + // The for-await above checks abort on each iteration, but a + // dispose racing the very last `next()` lands here. + if (this._abortController.signal.aborted) { + throw new CancellationError(); + } + throw new Error('Claude SDK stream ended without a result message'); + } catch (err) { + const fatal = err instanceof Error ? err : new Error(String(err)); + this._fatalError = fatal; + for (const req of this._inFlightRequests) { + if (!req.deferred.isSettled) { + req.deferred.error(fatal); + } + } + this._inFlightRequests = []; + throw fatal; + } + } +} + diff --git a/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts b/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts new file mode 100644 index 0000000000000..5fe2bf549a973 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import type { URI } from '../../../../base/common/uri.js'; +import type { ILogService } from '../../../log/common/log.js'; +import type { AgentSignal } from '../../common/agentService.js'; +import { ActionType } from '../../common/state/sessionActions.js'; +import { ResponsePartKind } from '../../common/state/sessionState.js'; + +/** + * Mutable mapping state owned by `ClaudeAgentSession` and threaded into + * {@link mapSDKMessageToAgentSignals}. Kept on the session — not in this + * module — so multiple sessions don't share state and the mapper itself + * stays a pure function. + */ +export interface IClaudeMapperState { + /** + * Maps content_block index → response part id. Populated on + * `content_block_start`, drained on `content_block_stop`, cleared on + * `message_start`. Used to route `content_block_delta` events to + * the right `SessionDelta` / `SessionReasoning` partId. + */ + readonly currentBlockParts: Map; +} + +/** + * Map one SDK message to zero or more agent signals. + * + * Pure function. All state is in {@link IClaudeMapperState}, which the + * caller owns. Tests can therefore exercise the mapper directly with a + * fake state object. + * + * Phase 6 emits: + * - {@link ActionType.SessionResponsePart} (Markdown) on + * `content_block_start` with a `text` block. + * - {@link ActionType.SessionResponsePart} (Reasoning) on + * `content_block_start` with a `thinking` block. + * - {@link ActionType.SessionDelta} on `content_block_delta` with a + * `text_delta`. + * - {@link ActionType.SessionReasoning} on `content_block_delta` with a + * `thinking_delta`. + * - {@link ActionType.SessionTurnComplete} on `result`. + * + * Reducer ordering invariant: `SessionResponsePart` MUST precede the + * first `SessionDelta` / `SessionReasoning` for that part id (see + * `actions.ts:233, 540`). This mapper allocates the part on + * `content_block_start` BEFORE any delta can arrive — deltas are + * SDK-ordered after the start — so the invariant holds by construction. + */ +export function mapSDKMessageToAgentSignals( + message: SDKMessage, + session: URI, + turnId: string, + state: IClaudeMapperState, + logService: ILogService, +): AgentSignal[] { + switch (message.type) { + case 'stream_event': + return mapStreamEvent(message.event, session, turnId, state, logService); + case 'result': + return mapResult(message, session, turnId); + default: + return []; + } +} + +function mapResult( + message: Extract, + session: URI, + turnId: string, +): AgentSignal[] { + const sessionStr = session.toString(); + const signals: AgentSignal[] = []; + if (message.subtype === 'success') { + // `modelUsage` is keyed by model name; pick the first key as the + // reported model. Phase 6 turns are single-model; multi-model + // attribution is a Phase 7+ concern. + const modelKey = Object.keys(message.modelUsage)[0]; + signals.push({ + kind: 'action', + session, + action: { + type: ActionType.SessionUsage, + session: sessionStr, + turnId, + usage: { + inputTokens: message.usage.input_tokens, + outputTokens: message.usage.output_tokens, + cacheReadTokens: message.usage.cache_read_input_tokens, + ...(modelKey ? { model: modelKey } : {}), + }, + }, + }); + } + signals.push({ + kind: 'action', + session, + action: { + type: ActionType.SessionTurnComplete, + session: sessionStr, + turnId, + }, + }); + return signals; +} + +function mapStreamEvent( + event: Extract['event'], + session: URI, + turnId: string, + state: IClaudeMapperState, + logService: ILogService, +): AgentSignal[] { + const sessionStr = session.toString(); + switch (event.type) { + case 'message_start': + state.currentBlockParts.clear(); + return []; + + case 'content_block_start': { + const block = event.content_block; + if (block.type === 'text') { + const partId = generateUuid(); + state.currentBlockParts.set(event.index, partId); + return [{ + kind: 'action', + session, + action: { + type: ActionType.SessionResponsePart, + session: sessionStr, + turnId, + part: { + kind: ResponsePartKind.Markdown, + id: partId, + content: '', + }, + }, + }]; + } + if (block.type === 'thinking') { + const partId = generateUuid(); + state.currentBlockParts.set(event.index, partId); + return [{ + kind: 'action', + session, + action: { + type: ActionType.SessionResponsePart, + session: sessionStr, + turnId, + part: { + kind: ResponsePartKind.Reasoning, + id: partId, + content: '', + }, + }, + }]; + } + // Defense in depth: `canUseTool: deny` should prevent tool_use + // from ever streaming, but if it does, skip + warn rather than + // allocating a part the reducer doesn't have a handler for. + if (block.type === 'tool_use') { + logService.warn(`[claudeMapSessionEvents] dropped streamed tool_use block at index ${event.index}`); + return []; + } + return []; + } + + case 'content_block_delta': { + const partId = state.currentBlockParts.get(event.index); + if (partId === undefined) { + return []; + } + if (event.delta.type === 'text_delta') { + return [{ + kind: 'action', + session, + action: { + type: ActionType.SessionDelta, + session: sessionStr, + turnId, + partId, + content: event.delta.text, + }, + }]; + } + if (event.delta.type === 'thinking_delta') { + return [{ + kind: 'action', + session, + action: { + type: ActionType.SessionReasoning, + session: sessionStr, + turnId, + partId, + content: event.delta.thinking, + }, + }]; + } + return []; + } + + case 'content_block_stop': + state.currentBlockParts.delete(event.index); + return []; + + case 'message_delta': + case 'message_stop': + return []; + + default: + return []; + } +} diff --git a/src/vs/platform/agentHost/node/claude/claudePromptResolver.ts b/src/vs/platform/agentHost/node/claude/claudePromptResolver.ts new file mode 100644 index 0000000000000..2c3df6b42e8d7 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/claudePromptResolver.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type Anthropic from '@anthropic-ai/sdk'; +import { URI } from '../../../../base/common/uri.js'; +import { IAgentAttachment } from '../../common/agentService.js'; +import { AttachmentType } from '../../common/state/sessionState.js'; + +/** + * Build the {@link Anthropic.ContentBlockParam}[] payload for an + * {@link SDKUserMessage} from a plain text prompt and the agent host's + * normalized attachment list. + * + * Phase 6 keeps the resolver pure and minimal: a single `text` block + * carrying the prompt, plus (when attachments are present) a second + * `text` block wrapped in `` tags listing the + * referenced URIs. This mirrors the production extension's resolver + * shape so a future phase that expands `IAgentAttachment` (binary + * images, inline range substitution) can port the existing branches + * without restructuring. + * + * **Selection branch is dead-code in Phase 6** — `AgentSideEffects` strips + * the `text` and `selection` fields from `IAgentAttachment` at the + * protocol → agent boundary (`agentSideEffects.ts:699-703`, `:934-938`), + * so the agent only ever sees `{ type, uri, displayName }`. The branch + * exists for forward-compat; activating it requires a separate change + * to the side-effects pipeline (out of Phase 6 scope). + */ +export function resolvePromptToContentBlocks( + prompt: string, + attachments?: readonly IAgentAttachment[], +): Anthropic.ContentBlockParam[] { + const blocks: Anthropic.ContentBlockParam[] = [{ type: 'text', text: prompt }]; + if (!attachments?.length) { + return blocks; + } + const refLines: string[] = []; + for (const att of attachments) { + switch (att.type) { + case AttachmentType.File: + case AttachmentType.Directory: + refLines.push(`- ${uriToString(att.uri)}`); + break; + case AttachmentType.Selection: { + const line = att.selection ? `:${att.selection.start.line + 1}` : ''; + refLines.push(`- ${uriToString(att.uri)}${line}`); + if (att.text) { + refLines.push('```'); + refLines.push(att.text); + refLines.push('```'); + } + break; + } + } + } + if (refLines.length === 0) { + return blocks; + } + blocks.push({ + type: 'text', + text: '\nThe user provided the following references:\n' + + refLines.join('\n') + + '\n\nIMPORTANT: this context may or may not be relevant to your tasks. ' + + 'You should not respond to this context unless it is highly relevant to your task.\n' + + '', + }); + return blocks; +} + +function uriToString(uri: URI): string { + return uri.scheme === 'file' ? uri.fsPath : uri.toString(); +} diff --git a/src/vs/platform/agentHost/node/claude/phase4-plan.md b/src/vs/platform/agentHost/node/claude/phase4-plan.md index 8cb809378ccb6..6cfdd0418e0f6 100644 --- a/src/vs/platform/agentHost/node/claude/phase4-plan.md +++ b/src/vs/platform/agentHost/node/claude/phase4-plan.md @@ -457,13 +457,13 @@ This is the proof Phase 4 actually ships. The unit tests prove the class is wire For Phase 4 specifically, the plan's per-phase table requires: -- [ ] **Gate verified disabled:** launch the Agents app *without* the env var (and with the setting off) and confirm only `CopilotAgent registered` appears in `agenthost.log` — no `ClaudeAgent registered`, no `'claude'` provider in root state. -- [ ] **Gate verified enabled:** re-launch via `launch-smoke.sh` (which sets `VSCODE_AGENT_HOST_ENABLE_CLAUDE=1`) and confirm both providers register. -- [ ] At least one `claude:/` session URI appears in the IPC log after the user picks Claude (the session URI scheme is `claude:`, **not** `agent-host-claude:` — the longer form is the synced-customization namespace, observable separately). -- [ ] The first user prompt surfaces `TODO: Phase 5` in the response area. (`createSession` is the earliest stub on the path; `sendMessage` is reached only after `createSession` succeeds, which lands in Phase 5.) -- [ ] Attach `registration.log`, `picker-open.png`, `stub-error.png`, and `claude-session-uris.log` to the PR. +- [~] **Gate verified disabled:** _skipped for the Phase 4 PR — covered by the unit-level gate test in `claudeAgent.test.ts` and the env-var guard in `agentHostMain.ts`. Re-enable for Phase 5._ +- [x] **Gate verified enabled:** re-launched via `launch-smoke.sh` (which sets `VSCODE_AGENT_HOST_ENABLE_CLAUDE=1`); both providers register. See `registration.log` (`Registering agent provider: copilotcli` + `…: claude`). +- [x] At least one `claude:/` session URI appears in the IPC log after the user picks Claude. Captured: `claude:/e32d3567-9da7-41c4-a71a-57daa0a6cf46` in `claude-session-uris.log`. +- [x] The first user prompt surfaces `TODO: Phase 5` in the response area. Captured in `todo-phase5-error.png`. +- [x] Smoke artifacts captured under `/tmp/claude-phase4-smoke//`: `registration.log`, `auth.log`, `proxy.log`, `claude-models.log` (46 Claude models), `claude-session-uris.log`, `root-state.log`, `picker-open.png`, `todo-phase5-error.png`, `smoke-summary.log`. Attach the four required artifacts (`registration.log`, `picker-open.png`, `stub-error.png`/`todo-phase5-error.png`, `claude-session-uris.log`) to the PR. -If any step in §7.8 fails, the PR is **not** ready regardless of whether §7.1–7.7 are green. +**Live-smoke completed: 2026-05-01.** All required Phase 4 invariants verified except the optional disabled-gate run (deferred — see above). ## 8. Resolved decisions diff --git a/src/vs/platform/agentHost/node/claude/phase5-plan.md b/src/vs/platform/agentHost/node/claude/phase5-plan.md new file mode 100644 index 0000000000000..9478ed302d6bb --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/phase5-plan.md @@ -0,0 +1,560 @@ +# Phase 5 Implementation Plan — `ClaudeAgent` session lifecycle + +> **Handoff plan** — written to be executed by an agent with no prior conversation context. All file paths and line citations are verified against the workspace at synthesis time. Cross-reference [roadmap.md](./roadmap.md) before committing exact phase numbers. + +## 1. Goal + +Replace the seven Phase-5 stubs in [claudeAgent.ts](claudeAgent.ts) (`createSession`, `disposeSession`, `getSessionMessages`, `listSessions`, `resolveSessionConfig`, `sessionConfigCompletions`, `shutdown`) with real implementations. **No live LLM traffic** in this phase — `sendMessage` stays a Phase-6 stub. The SDK's `query()` is **not** spawned in `createSession`. + +**Fork is explicitly out of scope.** SDK `forkSession` requires translating a protocol turn ID to an SDK event ID via `ClaudeAgentSession.getNextTurnEventId(...)`, which itself requires a live SDK session handle (CopilotAgent's reference at [`copilotAgent.ts:589-592`](../copilot/copilotAgent.ts#L589-L592) loads the source session via `_resumeSession` to do this). Phase 5 has no SDK session machinery, so the protocol-turn-ID → SDK-event-ID translation is structurally missing. Implementing fork on top of half-baked plumbing is the kind of corner-cutting that produces the latent bugs we're already trying to avoid in CopilotAgent. Phase 5 `createSession` therefore throws `TODO: Phase 6` when `config.fork` is set; Phase 6 picks up fork as part of its sendMessage / SDK-session work. + +**Exit criteria:** With the Phase-4 gate enabled, a workbench client can: + +1. Create a non-fork Claude session and receive a `claude:/` URI. +2. List sessions and see entries from this agent host AND externally-created Claude Code sessions (CLI, other clients). +3. Dispose a session cleanly without affecting external listings. +4. Shut down the agent host cleanly. +5. `createSession({ fork })` throws `TODO: Phase 6`. +6. The first `sendMessage` call still throws `TODO: Phase 6` (sendMessage is Phase 6, not Phase 5). + +## 2. Files to create / modify + +| Action | File | Purpose | +|---|---|---| +| **Create** | [claudeAgentSdkService.ts](claudeAgentSdkService.ts) | Lazy `@anthropic-ai/claude-agent-sdk` wrapper. Phase-5 surface: `listSessions`, `getSessionMessages`. **No `query()` yet, no `forkSession` yet — fork is Phase 6.** | +| **Create** | [claudeAgentSession.ts](claudeAgentSession.ts) | Per-session wrapper. Phase-5 fields: `sessionId`, `sessionUri`. `dispose()` is no-op-safe. Class grows in Phase 6 to hold `_query`, `_abortController`, etc. | +| **Modify** | [claudeAgent.ts](claudeAgent.ts) | Replace 7 stubs. Add `ISessionDataService` + `IClaudeAgentSdkService` DI. Add `_sessions: DisposableMap`, `_disposeSequencer: SequencerByKey`, `_shutdownPromise?: Promise`. | +| **Modify** | [../agentHostMain.ts](../agentHostMain.ts) | Register `IClaudeAgentSdkService` next to `IClaudeProxyService`. | +| **Modify** | [../agentHostServerMain.ts](../agentHostServerMain.ts) | Same registration as `agentHostMain.ts`. | +| **Modify** | [/package.json](../../../../../../package.json) | Add `@anthropic-ai/claude-agent-sdk` at version **`0.2.112`** (versions > 0.2.112 add native deps — out of scope until Phase 15 per [roadmap.md §15](roadmap.md)). | +| **Modify** | [/remote/package.json](../../../../../../remote/package.json) | Same dep — agent host runs in the remote bundle too. | +| **Modify** | [../../test/node/claudeAgent.test.ts](../../test/node/claudeAgent.test.ts) | Add `FakeClaudeAgentSdkService`. Replace stub-throw assertions for the 6 Phase-5-implemented methods with lifecycle cases (fork still throws). Add the mandatory cases in §5. | + +## 3. Implementation spec + +### 3.1 `IClaudeAgentSdkService` — lazy SDK wrapper + +Mirrors the lazy-import pattern at [`claudeCodeSdkService.ts:78-93`](../../../../../../extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeSdkService.ts#L78-L93). The agent host runs in Electron's utility process; the dynamic `import()` keeps the heavy SDK out of cold-start paths and isolates the native-deps boundary. + +```ts +export const IClaudeAgentSdkService = createDecorator('claudeAgentSdkService'); + +export interface IClaudeAgentSdkService { + readonly _serviceBrand: undefined; + listSessions(): Promise; + getSessionMessages(sessionId: string): Promise; + // forkSession added in Phase 6 — fork requires a live SDK session handle + // for protocol-turn-ID → SDK-event-ID translation; see §1. +} + +export class ClaudeAgentSdkService implements IClaudeAgentSdkService { + declare readonly _serviceBrand: undefined; + + constructor(@ILogService private readonly _logService: ILogService) { } + + /** + * Cached resolved module. We deliberately cache the *resolved* value, not + * the promise \u2014 if the dynamic import throws, the next call retries. + * Mirrors the convention in [`agentHostTerminalManager.ts:60-66`](../agentHostTerminalManager.ts#L60-L66) + * for `node-pty`. Retry cost is acceptable here because `listSessions()` + * is called per user action (workbench open, refresh), not in a polling + * loop. The first failure is logged via {@link _logFirstLoadFailure} so + * a corrupt `node_modules` shows up clearly without flooding logs. + */ + private _sdkModule: typeof import('@anthropic-ai/claude-agent-sdk') | undefined; + private _firstLoadFailureLogged = false; + + protected async _loadSdk(): Promise { + if (this._sdkModule) { + return this._sdkModule; + } + try { + this._sdkModule = await import('@anthropic-ai/claude-agent-sdk'); + return this._sdkModule; + } catch (err) { + if (!this._firstLoadFailureLogged) { + this._firstLoadFailureLogged = true; + this._logService.error('[ClaudeAgentSdkService] Failed to load @anthropic-ai/claude-agent-sdk; will retry on next call.', err); + } + throw err; + } + } + + async listSessions(): Promise { + const sdk = await this._loadSdk(); + return sdk.listSessions(undefined); + } + // getSessionMessages similarly +} +``` + +**Phase-5 surface only.** No `query()` export, no `forkSession` \u2014 those land in Phase 6. + +### 3.2 `ClaudeAgentSession` — per-session wrapper (minimal) + +Phase-5 fields are the bare minimum. The class grows substantially in Phase 6. + +```ts +export class ClaudeAgentSession extends Disposable { + constructor( + readonly sessionId: string, + readonly sessionUri: URI, + readonly workingDirectory: URI | undefined, + ) { + super(); + } + + // Phase 6 will add: _query, _abortController, _pendingPrompt, etc. + // For Phase 5, dispose() is the inherited no-op — nothing yet to tear down. +} +``` + +**Working-directory ownership.** The wrapper is the single in-memory source of truth for the session's working directory while live, mirroring CopilotAgent's pattern (`CopilotAgentSession` and `IProvisionalSession` both hold `workingDirectory` directly — see [`copilotAgent.ts:603-615`](../copilot/copilotAgent.ts#L603-L615)). Persistence flows through `setMetadata('claude.customizationDirectory', …)` on fork and (Phase 6) on first `sendMessage`; resume-from-disk reconstructs the wrapper from that metadata. Phase 5 marks the field `readonly` because pre-prompt drafts can't change folder mid-life; Phase 6 may convert it to a settable field when worktree materialization is introduced (the worktree URI replaces the original folder while the customization-directory metadata still anchors plugin discovery to the user's pick). + +This file exists in Phase 5 chiefly to nail down the import shape and DI boundary so Phase 6 is a pure-additive change. + +### 3.3 `ClaudeAgent` — DI updates and lifecycle methods + +Add three constructor deps, three private fields, and one metadata-key constant (Claude-namespaced, mirroring CopilotAgent's `_META_CUSTOMIZATION_DIRECTORY` at [`copilotAgent.ts:1304`](../copilot/copilotAgent.ts#L1304)): + +```ts +private static readonly _META_CUSTOMIZATION_DIRECTORY = 'claude.customizationDirectory'; + +constructor( + @ILogService private readonly _logService: ILogService, + @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, + @IClaudeProxyService private readonly _claudeProxyService: IClaudeProxyService, + @ISessionDataService private readonly _sessionDataService: ISessionDataService, // NEW + @IClaudeAgentSdkService private readonly _sdkService: IClaudeAgentSdkService, // NEW +) { super(); } + +private readonly _sessions = this._register(new DisposableMap()); +private readonly _disposeSequencer = new SequencerByKey(); +private _shutdownPromise: Promise | undefined; +``` + +Both `agentHostMain.ts` and `agentHostServerMain.ts` use `instantiationService.createInstance(ClaudeAgent)` already — DI resolves the new deps automatically once they are registered (see §3.6). + +#### 3.3.1 `createSession` + +**Fork is deferred to Phase 6** (see §1). When `config.fork` is set, throw `Error('TODO: Phase 6: fork requires SDK session handle for protocol-turn-ID → SDK-event-ID translation')`. The non-fork path is in-memory only; no DB writes, no SDK calls. + +`AgentService.createSession` ([`agentService.ts:269-282`](../agentService.ts#L269-L282)) **already** builds `config.fork.turnIdMapping` from the source session's turns BEFORE calling `provider.createSession(config)`. Providers are consumers of the mapping, not authors. Phase 6 implementation will use this; Phase 5 ignores the field by virtue of throwing. + +**Post-PR #313841 invariant** (relevant for Phase 6 once fork lands): AgentService drops `config.fork` for sources with zero turns ([`agentService.ts:269-282`](../agentService.ts#L269-L282)) — a forkless source is indistinguishable from a fresh session, so the call falls through to the non-fork path. Phase 6's fork branch will therefore be guaranteed `config.fork.session` has ≥ 1 turn and `config.fork.turnIdMapping` is non-empty. + +```ts +async createSession(config: IAgentCreateSessionConfig): Promise { + if (config.fork) { + // Fork requires translating `config.fork.turnId` (a protocol turn ID) + // to an SDK event ID via the live source SDK session handle. Phase 5 + // has no SDK session machinery, so the translation is structurally + // unavailable. Phase 6 picks this up alongside sendMessage by + // resuming the source via `_resumeSession` and calling + // `getNextTurnEventId(...)` (mirrors CopilotAgent at + // copilotAgent.ts:589-592). + throw new Error('TODO: Phase 6: fork requires SDK session handle'); + } + + // Non-fork path: in-memory only. Mirrors Claude Code's "no message → no session" + // semantic. First sendMessage (Phase 6) writes the SDK session record and + // metadata. AgentService now eagerly creates sessions on folder-pick (PR #313841) + // and arms a 30s GC that calls disposeSession if the user abandons the + // new-chat view; for an empty Claude session that's a cheap in-memory drop + // because nothing has been persisted yet. Note: we do NOT set + // `provisional: true` on the result — that opt-in would defer + // `sessionAdded` until ClaudeAgent fires `onDidMaterializeSession`, but + // Phase 5 has no SDK session to materialize. Returning without + // `provisional` makes AgentService dispatch `SessionReady` immediately + // (the desired behaviour for Claude until Phase 6 introduces real + // materialization work). + const sessionId = generateUuid(); + const sessionUri = AgentSession.uri(this.id, sessionId); + const session = new ClaudeAgentSession(sessionId, sessionUri, config.workingDirectory); + this._sessions.set(sessionId, session); + return { session: sessionUri, workingDirectory: config.workingDirectory }; +} +``` + +Note: `IAgentCreateSessionConfig` carries `workingDirectory?: URI` — there is no `customizationDirectory` field on the config. The customization directory is the user-picked folder (Claude doesn't materialize a worktree until Phase 6 / Phase 15), so `config.workingDirectory` is the right source for both purposes in Phase 5. The return type is `IAgentCreateSessionResult` ([`agentService.ts:124-145`](../../common/agentService.ts#L124-L145)); we populate `session` and `workingDirectory` and intentionally omit `provisional`. + +#### 3.3.2 `listSessions` + +**SDK is source of truth.** Per-session DB is overlay/cache only. External Claude Code sessions (CLI, other clients) MUST surface — that's a Phase-5 exit criterion. + +CopilotAgent's pattern at `copilotAgent.ts:519-541` has a latent bug: `Promise.all` over fan-out reads where any rejection drops the whole listing. ClaudeAgent must follow the resilient pattern at [`agentService.ts:188-204`](../../common/agentService.ts#L188-L204) — each iteration wraps its own try/catch and returns the SDK-provided entry on failure. + +```ts +async listSessions(): Promise { + const sdkEntries = await this._sdkService.listSessions(); + return Promise.all(sdkEntries.map(async entry => { + // Per-session DB overlay. Failure here NEVER excludes the session. + try { + const sessionUri = AgentSession.uri(this.id, entry.sessionId); + const dbRef = await this._sessionDataService.tryOpenDatabase(sessionUri); + if (dbRef) { + try { + const customizationDirectory = await dbRef.object.getMetadata( + ClaudeAgent._META_CUSTOMIZATION_DIRECTORY, + ); + return this._toAgentSessionMetadata(entry, { customizationDirectory }); + } finally { + dbRef.dispose(); + } + } + } catch (err) { + this._logService.warn(err, `[Claude] Overlay read failed for session ${entry.sessionId}`); + } + // External session, or DB read failed: surface what the SDK gave us. + return this._toAgentSessionMetadata(entry, {}); + })); +} +``` + +**No filter** like CopilotAgent's `if (!metadata) return undefined` at `copilotAgent.ts:521-523`. That filter is what hides external sessions today; ClaudeAgent doesn't reproduce it. Title / isRead / isArchived / diffs decoration is already handled generically by [`AgentService.listSessions`](../../common/agentService.ts#L188-L204). + +**No `dir` scoping.** `IAgent.listSessions()` has no `dir` parameter ([`agentService.ts:467`](../../common/agentService.ts#L467)) and `IClaudeAgentSdkService.listSessions()` mirrors that surface. The SDK service translates this to `sdk.listSessions(undefined)` internally — the host doesn't expose `dir` plumbing. If/when `IAgent` grows an optional `dir`, the SDK service surface grows in lockstep. + +#### 3.3.3 `getSessionMessages` + +```ts +async getSessionMessages(_session: URI): Promise { + return []; // Phase 13 owns full transcript reconstruction. +} +``` + +A code comment must reference Phase 13 explicitly so future readers don't silently fill this in. + +#### 3.3.4 `disposeSession` + +Sequencer-serialized. Removes the wrapper from `_sessions`. Does **NOT** delete the SDK session, does **NOT** delete the DB — Phase 13 owns deletion. + +```ts +disposeSession(session: URI): Promise { + const sessionId = AgentSession.id(session); + return this._disposeSequencer.queue(sessionId, async () => { + this._sessions.deleteAndDispose(sessionId); // safe if missing + }); +} +``` + +#### 3.3.5 `resolveSessionConfig` / `sessionConfigCompletions` + +Decision **B5** from the planning conversation: Claude-native single-axis schema. The platform `Mode`/`AutoApprove` keys are subsumed by `permissionMode`. The `Permissions` key is reused from `platformSessionSchema` because Claude SDK accepts `allowedTools` / `disallowedTools` natively, so the platform key is a faithful representation. + +Add a new file [../../common/claudeSessionConfigKeys.ts](../../common/claudeSessionConfigKeys.ts): + +```ts +export const enum ClaudeSessionConfigKey { + PermissionMode = 'permissionMode', +} +``` + +Implementation: + +```ts +async resolveSessionConfig(_session: URI | undefined): Promise { + const sessionSchema = createSchema({ + [ClaudeSessionConfigKey.PermissionMode]: schemaProperty({ + type: 'string', + title: localize('claude.sessionConfig.permissionMode', "Approvals"), + description: localize('claude.sessionConfig.permissionModeDescription', "How Claude handles tool approvals."), + enum: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], + enumLabels: [ + localize('claude.sessionConfig.permissionMode.default', "Ask Each Time"), + localize('claude.sessionConfig.permissionMode.acceptEdits', "Auto-Approve Edits"), + localize('claude.sessionConfig.permissionMode.bypassPermissions', "Bypass Approvals"), + localize('claude.sessionConfig.permissionMode.plan', "Plan Only (Read-Only)"), + ], + enumDescriptions: [ + localize('claude.sessionConfig.permissionMode.defaultDescription', "Prompt for every tool call."), + localize('claude.sessionConfig.permissionMode.acceptEditsDescription', "Auto-approve file edits; prompt for shell and other tools."), + localize('claude.sessionConfig.permissionMode.bypassPermissionsDescription', "Auto-approve every tool call."), + localize('claude.sessionConfig.permissionMode.planDescription', "Read-only research mode; no tool calls executed."), + ], + default: 'default', + sessionMutable: true, + }), + [SessionConfigKey.Permissions]: platformSessionSchema.definition[SessionConfigKey.Permissions], + }); + return { + schema: sessionSchema, + values: { /* defaults applied by the caller via schema.default */ }, + }; +} + +async sessionConfigCompletions(_session: URI | undefined, _property: string, _query: string): Promise { + return { items: [] }; // permissionMode is enum; no dynamic completion needed +} +``` + +**Skipped keys:** +- `SessionConfigKey.AutoApprove`, `SessionConfigKey.Mode` — subsumed by `permissionMode`. +- `SessionConfigKey.Isolation`, `Branch`, `BranchNameHint` — deferred to Phase 6 prerequisite (§8 worktree-extraction note). + +**Why this works for the workbench UI** (verified live): +- [`AgentHostModePicker`](../../../../../sessions/contrib/chat/browser/agentHost/agentHostModePicker.ts#L128-L141) renders nothing when `schema.properties[Mode]` is absent or fails `isWellKnownModeSchema()`. Claude sessions don't show a mode picker — the right behavior. +- [`AgentHostSessionConfigPicker`](../../../../../sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts#L326) is the generic per-property fallback. It renders a dropdown for any string-enum property in the schema. **`permissionMode` gets a dropdown for free, no workbench changes needed.** +- The pre-existing `ClaudePermissionModePicker` (`src/vs/sessions/contrib/copilotChatSessions/browser/claudePermissionModePicker.ts`) is for **extension-based** Claude (`CopilotChatSessionsProvider`), not agent-host Claude. The two coexist via `when` clauses. Eventually the extension picker should be deleted in favor of the generic schema-driven path; that cleanup is documented as tech debt in `COPILOT_CHAT_SESSIONS_PROVIDER.md:157` and is out of scope for Phase 5. + +#### 3.3.6 `shutdown` and `dispose` + +Memoized idempotent shutdown. Mirrors CopilotAgent's pattern at [`copilotAgent.ts:1057-1068`](../copilot/copilotAgent.ts#L1057-L1068). + +```ts +shutdown(): Promise { + this._shutdownPromise ??= (async () => { + // Phase 6+ INVARIANT: SDK Query subprocesses MUST be aborted before + // disposing the proxy handle, AND any in-flight createSession / + // sendMessage I/O must be drained first. Phase 5 has no Query + // objects and no async createSession path (fork is Phase 6), so the + // _sessions map only holds in-memory wrappers — disposal here is + // sequencing for Phase 6, not real teardown work. Phase 6 will + // introduce `_inFlightCreates: Set>` and prepend + // `await Promise.allSettled([...this._inFlightCreates])` to this + // body when fork + sendMessage materialization land. + // + // Per-session teardown goes through `_disposeSequencer` so a + // concurrent `disposeSession(uri)` already in flight is awaited + // before shutdown reuses the same key. In Phase 5 the queued work + // is synchronous, so the sequencer is mostly a no-op; the routing + // matters in Phase 6 when teardown grows real async work (Query + // abort, in-flight metadata writes). + const sessionIds = [...this._sessions.keys()]; + await Promise.all(sessionIds.map(sessionId => + this._disposeSequencer.queue(sessionId, async () => { + this._sessions.deleteAndDispose(sessionId); + }) + )); + })(); + return this._shutdownPromise; +} + +override async dispose(): Promise { + await this.shutdown(); // ordered: drain sessions + this._proxyHandle?.dispose(); // then release proxy refcount + this._proxyHandle = undefined; + this._githubToken = undefined; + this._models.set([], undefined); + super.dispose(); +} +``` + +The `await shutdown(); _proxyHandle?.dispose();` ordering preserves the Phase-4 invariant comment at `claudeAgent.ts:241-248`. **In Phase 6 this becomes load-bearing** — Query subprocesses talk to the proxy and must die first. + +### 3.4 DI registration + +Both `agentHostMain.ts` and `agentHostServerMain.ts` already register `ICopilotApiService` and `IClaudeProxyService`. Add `IClaudeAgentSdkService` next to `IClaudeProxyService` in **both** files: + +```ts +const claudeAgentSdkService = disposables.add(instantiationService.createInstance(ClaudeAgentSdkService)); +diServices.set(IClaudeAgentSdkService, claudeAgentSdkService); +``` + +If `ClaudeAgentSdkService` doesn't need disposal (no held resources beyond the lazy SDK module reference), the `disposables.add()` wrapper is still the right call \u2014 the codebase convention at [`agentHostMain.ts:112`](../agentHostMain.ts#L112) wraps `ClaudeProxyService` unconditionally even when there's nothing meaningful to release. Symmetry over micro-optimization. + +### 3.5 No subagent parsing in Phase 5 + +`parseSubagentSessionUri` and the `subagentOf` URI authority are explicitly **deferred to Phase 12**. ClaudeAgent's session URIs in Phase 5 are flat: `claude:/`. + +`listSessions` is safe to use unfiltered: the SDK's `listSessions(_options?)` only enumerates top-level sessions by filesystem layout convention. Subagent transcripts live in a nested `subagents/agent-.jsonl` directory inside the parent session's storage and are only reachable through the separate `listSubagents(sessionId)` API (Phase 12+). The returned `SDKSessionInfo` shape carries no parent/subagent discriminator field, so filtering at this layer would be impossible regardless — but it isn't needed. Verified against `@anthropic-ai/claude-agent-sdk@0.2.112`'s `sdk.d.ts`. + +### 3.6 No `IClaudeSessionTranscriptStore` seam + +The roadmap originally proposed introducing `IClaudeSessionTranscriptStore` in Phase 5 as a seam for the future hybrid (SDK + `sessionStore` alpha) implementation. **Deferred to Phase 13** by 2-of-3 reviewer consensus — the seam is dead code today and Phase 13 (transcript reconstruction) is the natural place to introduce it. `getSessionMessages` returns `[]` directly in Phase 5. + +## 4. Persistence model (the load-bearing decision) + +| Source | Owns | Phase 5 reads | Phase 5 writes | +|---|---|---|---| +| **SDK** (`@anthropic-ai/claude-agent-sdk` JSONL on disk) | Session existence, transcripts, last-modified | `listSessions` | None — fork (which would write) is Phase 6 | +| **Per-session DB** (`ISessionDataService.openDatabase(uri)`) | Overlay/cache: `customizationDirectory` (Claude-namespaced), project info | `listSessions` (overlay only) | None in Phase 5 — fork's `vacuumInto`/`remapTurnIds`/`setMetadata` and sendMessage's metadata write are both Phase 6 | +| **In-memory `_sessions` map** | Active wrapper objects, dispose lifecycle | `disposeSession` | `createSession` (non-fork) | + +**Three rules:** + +1. **Non-fork `createSession` does NOT touch disk.** First `sendMessage` (Phase 6) writes the SDK session record. Pre-prompt drafts that the user abandons (workspace switch, new-chat close) are GC'd by AgentService 30 s after the last subscriber drops via `disposeSession` (PR #313841, [`agentService.ts SESSION_GC_GRACE_MS`](../agentService.ts)) — for Claude this is a cheap in-memory wrapper drop because no DB row exists yet. +2. **Fork `createSession` is unimplemented in Phase 5** (throws `TODO: Phase 6`). Phase 6 will add the vacuum + remap + setMetadata pipeline alongside the SDK-session machinery that translates protocol turn IDs to SDK event IDs. The DB schema and metadata key (`'claude.customizationDirectory'`) are reserved for Phase 6's use. +3. **`listSessions` never excludes a session because of DB read failure.** The SDK is the source of truth; the DB is decoration. + +## 5. Test file spec + +Modify [`../../test/node/claudeAgent.test.ts`](../../test/node/claudeAgent.test.ts). The existing 14 Phase-4 cases stay; replace the stub-throw assertions for the 6 Phase-5-implemented methods (fork still throws, kept) and add the new lifecycle cases below. + +**New fakes:** + +- `FakeClaudeAgentSdkService` implementing `IClaudeAgentSdkService` (Phase-5 surface: `listSessions` + `getSessionMessages` only). Configurable `_sessionList: SDKSessionInfo[]`. Track call counts for verification. +- Reuse [`createNullSessionDataService()`](../../test/common/sessionTestHelpers.ts) (in-memory variant) — extend it inline in the test file if a richer fake is needed (e.g. to simulate a corrupt DB by having `tryOpenDatabase` reject for one specific sessionId). +- `RecordingLogService extends NullLogService` — overrides `error(...)` to push the args into a public `errorCalls: unknown[][]` array. Used by test 11 to assert the log-once contract on `_loadSdk` failures. +- `TestableClaudeAgentSdkService extends ClaudeAgentSdkService` — overrides the protected `_loadSdk()` method to throw on demand (controlled by a public `failNext: boolean` flag). Used by test 11 to simulate dynamic-import failure without touching `node_modules`. + +**Mandatory cases** (use `assert.deepStrictEqual` for snapshot-style assertions per repo guideline): + +1. **`createSession` non-fork — no DB writes, no SDK calls.** Returns `claude:/` URI; UUID is host-minted (`generateUuid()` shape). Assert via fakes that **none of `openDatabase`, `tryOpenDatabase`, or any `IClaudeAgentSdkService` method was called.** +2. **`createSession({ fork })` throws `TODO: Phase 6`.** With `config.fork = { session, turnId, turnIndex, turnIdMapping }` set, `createSession` rejects with an error whose message contains `"Phase 6"`. Assert no entry was added to `_sessions`, no DB was opened, and no SDK call was made. +3. **`listSessions` returns SDK entries decorated with overlay.** Two SDK sessions: one has a local DB with `customizationDirectory: '/foo'`, one doesn't. Assert both surface; only the first carries the overlay value. +4. **`listSessions` includes external sessions.** Sessions surfaced by the SDK that have no local DB at all (external Claude Code CLI sessions) MUST appear in the result with whatever fields the SDK provided. +5. **`listSessions` resilience: corrupt-DB does not poison the listing.** Three SDK sessions; fake `tryOpenDatabase` rejects for one specific sessionId. Result still has all three entries (the corrupt one falls back to the SDK-only entry, not undefined). +6. **`getSessionMessages` returns `[]`** — comment in test cites Phase 13. +7. **`disposeSession` removes from `_sessions`, leaves SDK + DB alone.** Subsequent `listSessions` (still driven by SDK) shows the session — `dispose` is a wrapper-removal, not a deletion. +8. **`disposeSession` is safe for unknown sessionId** — no-op, no throw. +9. **`shutdown` is idempotent** — call twice in parallel; second call returns the same memoized promise; no double-iteration over `_sessions`. +10. **`dispose` ordering: shutdown then proxy.** Use a sentinel proxy handle whose `dispose()` records a timestamp; after `agent.dispose()`, assert the recorded shutdown completion strictly precedes the proxy disposal. +11. **`ClaudeAgentSdkService` log-once-on-failure.** Construct a `TestableClaudeAgentSdkService` with `failNext = true` and a `RecordingLogService`. Call `listSessions()` twice in sequence; both calls reject. Assert `recordingLogService.errorCalls.length === 1` (NOT 2). Then set `failNext = false` and resolve `_sdkModule` to a stub returning `[]`; `listSessions()` resolves and `errorCalls.length` stays at 1 (success doesn't re-log). This locks the contract that diagnosis logs aren't spammy. +12. **`shutdown` and `disposeSession` share the dispose sequencer (Phase-6 race guard).** Inject a `ClaudeAgentSession` subclass whose `dispose()` increments a per-instance `disposeCount` and (optionally) awaits a deferred to slow teardown to a controllable scale. Create two sessions, fire `agent.disposeSession(s1)` and `agent.shutdown()` without awaiting either, then resolve all deferreds. Assert each wrapper's `disposeCount === 1` (NOT 2 — no double-dispose). Assert `_sessions` is empty afterwards. The test passes trivially in Phase 5 (sync dispose), but locks the contract so Phase 6's real async teardown can't regress. + +**Resolved-config cases** (replace existing stub-throw assertions): + +13. **`resolveSessionConfig`** returns a schema with `permissionMode` (4-value enum) and `Permissions` (the platform key), and **no other** properties. Snapshot-compare the schema definition. +14. **`sessionConfigCompletions`** returns `{ items: [] }` for any property/query. + +Use `ensureNoDisposablesAreLeakedInTestSuite()` at the top of the suite (already there from Phase 4). + +## 6. Risks / gotchas + +| Risk | Mitigation | +|---|---| +| `@anthropic-ai/claude-agent-sdk@0.2.112` may pull native deps via postinstall. | After `npm install`, run `npm ls @anthropic-ai/claude-agent-sdk`. Verify pure-JS shape — no `node-gyp` rebuilds, no platform-specific binary downloads. If 0.2.112 has native steps, escalate before merging Phase 5; the roadmap's Phase 15 boundary (versions > 0.2.112 add native deps) implies 0.2.112 itself is clean, but verify. | +| Lazy `import('@anthropic-ai/claude-agent-sdk')` in a utility process Node context. | Extension uses the same pattern; agent host runs in Electron's utility process. Validate with the live smoke (§7.6) before declaring done. Low risk but a real failure mode. | +| SDK dynamic-import fails (corrupt `node_modules`, postinstall failure). | `_loadSdk` caches the resolved module on success and retries on failure (matches `agentHostTerminalManager.ts` node-pty pattern). First failure is logged once via `ILogService.error` so it's diagnosable; subsequent failures retry silently. `listSessions` is per user action, not a polling loop, so retry storms aren't a concern. | +| `Promise.all` over fan-out reads silently corrupts `listSessions`. | §3.3.2 inner-try/catch pattern. Test 5 codifies the invariant. **Do not copy CopilotAgent's structure verbatim — it has the bug.** | +| `disposeSession` race with concurrent `listSessions` reading `_sessions`. | `_disposeSequencer.queue(sessionId, ...)` serializes per-session teardown. `listSessions` reads from the SDK, not `_sessions`, so the race is moot in practice — but the sequencer matters in Phase 6 when teardown also aborts a `Query`. | +| `disposeSession(uri)` racing concurrent `shutdown()` could double-dispose the same wrapper in Phase 6. | `shutdown()` routes per-session teardown through the same `_disposeSequencer` that `disposeSession` uses, so an in-flight per-session call is awaited before shutdown disposes the same key. Phase 5 dispose is synchronous so the race is benign, but the routing is locked in now so Phase 6's real async teardown (`Query` abort, in-flight metadata writes) inherits the serialization for free. Test 12 codifies the contract. | +| Fork is unimplemented in Phase 5; workbench may attempt to fork. | `createSession({ fork })` throws `TODO: Phase 6`; the workbench surfaces this as a session-creation error. UX impact: "Restart from here" / similar fork triggers will fail visibly when targeting a Claude session. Acceptable because (a) Phase 5 is gated behind a setting and an env var, (b) Phase 6 closes the gap. Test 10 codifies the throw. | +| Phase-6 `dispose` order silently regressed. | Test 10 (sentinel-timestamp) catches inversion. Comment block at the top of `dispose()` cites the invariant. | +| Pre-prompt drafts disappear when the user abandons new-chat. | Intentional. Per PR #313841, AgentService eagerly creates the session on folder-pick and arms a 30 s GC timer that fires `disposeSession` if the last subscriber drops while the session has zero turns. For Claude that means createSession + disposeSession is silently exercised every time a user opens new-chat and walks away — both must be cheap. The non-fork path is in-memory only and Phase-6 disposeSession will be a wrapper drop, so this is fine. Test 1 codifies the no-DB-write invariant. | +| `createSession` and `disposeSession` are now hot paths (folder-pick + 30 s GC). | Phase 5 createSession is in-memory for the only implemented case (non-fork) → cheap. Phase 6 disposeSession must stay cheap; if Claude later needs heavier setup at create time we can opt into the `provisional`/`onDidMaterializeSession` pattern (PR #313841) instead of paying it eagerly. | +| External-session UI rendering: `SDKSessionInfo` may not include `cwd` / `workingDirectory`. | Phase 5 surfaces what the SDK gives us. If the chat UI needs `cwd` to render a sensible label, Phase 13 (transcript reconstruction) will add JSONL-derived enrichment. Not a Phase-5 blocker. | +| `IAgent.listSessions()` has no `dir` parameter. | `IClaudeAgentSdkService.listSessions()` mirrors the surface (no `dir` parameter). Internally it calls `sdk.listSessions(undefined)`. Future enhancement if/when `IAgent` gains an optional `dir`. | +| Workbench UI lacks a permission-mode picker for Claude sessions. | The generic `AgentHostSessionConfigPicker` auto-renders any string-enum property. Verified live (§3.3.5). No workbench code changes needed in Phase 5. | +| Both `agentHostMain.ts` and `agentHostServerMain.ts` need the new SDK service registration. | §3.4 lists both. Forgetting `agentHostServerMain.ts` causes server-mode crashes the same way Phase 4 missed it. | + +## 7. Acceptance criteria + +The PR is **done** when every box below is checked. Run them in order — earlier failures invalidate later checks. + +### 7.1 Code structure + +- [ ] [claudeAgentSdkService.ts](claudeAgentSdkService.ts) exports `IClaudeAgentSdkService` decorator + `ClaudeAgentSdkService` impl. Lazy SDK module load (cached on success, retries on failure, logs first failure once — mirrors `agentHostTerminalManager.ts` node-pty pattern). Phase-5 surface only (`listSessions`, `getSessionMessages` — no `forkSession`, no `query()`). +- [ ] [claudeAgentSession.ts](claudeAgentSession.ts) exports `ClaudeAgentSession extends Disposable` with `sessionId`, `sessionUri`, `workingDirectory` fields. No `_query` / `_abortController` yet. +- [ ] [claudeAgent.ts](claudeAgent.ts) constructor adds `@ISessionDataService` + `@IClaudeAgentSdkService`. Class adds `_sessions: DisposableMap`, `_disposeSequencer: SequencerByKey`, `_shutdownPromise?: Promise`. +- [ ] All 7 Phase-5 stubs are real implementations or, in the case of `createSession` with `config.fork`, throw `TODO: Phase 6`. None throw `TODO: Phase 5`. +- [ ] Phase-6+ stubs (`sendMessage`, `respondToPermissionRequest`, etc.) still throw `TODO: Phase N`. +- [ ] `dispose()` order is `await shutdown(); _proxyHandle?.dispose(); super.dispose();` with a comment citing the Phase-6 invariant. +- [ ] Microsoft copyright header on every new file. +- [ ] No `as any` / `as unknown as Foo` casts in test or production code. + +### 7.2 Schema & DI + +- [ ] [../../common/claudeSessionConfigKeys.ts](../../common/claudeSessionConfigKeys.ts) exists exporting `ClaudeSessionConfigKey.PermissionMode = 'permissionMode'`. +- [ ] `resolveSessionConfig` returns ONLY `permissionMode` + reused `Permissions` from `platformSessionSchema`. No `AutoApprove`, no `Mode`, no `Isolation`, no `Branch`, no `BranchNameHint`. +- [ ] Both `agentHostMain.ts` AND `agentHostServerMain.ts` register `IClaudeAgentSdkService` next to `IClaudeProxyService`. + +### 7.3 Persistence invariants (assert in tests) + +- [ ] Non-fork `createSession` does NOT call `ISessionDataService.openDatabase` or `tryOpenDatabase`, and does NOT call any `IClaudeAgentSdkService` method. +- [ ] `createSession({ fork })` rejects with a `TODO: Phase 6` error and produces no side effects (no `_sessions` entry, no DB call, no SDK call). +- [ ] `listSessions` returns one entry per SDK session, including those with no local DB. +- [ ] `listSessions` is resilient to single-DB-read failure (no `Promise.all`-over-throwables corruption). + +### 7.4 Compile + lint + layers + +- [ ] `VS Code - Build` task shows zero TypeScript errors. If task is unavailable, `npm run compile-check-ts-native` exits 0. +- [ ] `npm run eslint -- src/vs/platform/agentHost/node/claude src/vs/platform/agentHost/test/node/claudeAgent.test.ts` exits 0. +- [ ] `npm run valid-layers-check` exits 0. +- [ ] `npm run hygiene` exits 0. +- [ ] `npm ls @anthropic-ai/claude-agent-sdk` shows exactly `0.2.112`, no native build steps in the install log. + +### 7.5 Tests + +- [ ] All 14 Phase-4 cases still pass. +- [ ] All 14 new cases from §5 pass. +- [ ] `scripts/test.sh --grep ClaudeAgent` exits 0. +- [ ] `ensureNoDisposablesAreLeakedInTestSuite()` is at the top of the suite (preserved from Phase 4). + +### 7.6 Live-system smoke (mandatory before merging) + +Follow the Phase-4 smoke harness ([smoke.md](smoke.md), [scripts/launch-smoke.sh](scripts/launch-smoke.sh)). Phase-5 additions: + +- [ ] **Disabled-gate run executed** (deferred for Phase 4 per [phase4-plan.md §7.8](phase4-plan.md); re-required for Phase 5). With `chat.agentHost.claudeAgent.enabled: false` and no env var, the workbench shows only `'copilotcli'` in root state. +- [ ] **Enabled-gate run.** Pick Claude in the picker; observe `claude:/` in the IPC log (same evidence shape as Phase 4 — but now `createSession` succeeded for real, not via TODO). +- [ ] **First user prompt now surfaces `TODO: Phase 6`**, not `TODO: Phase 5`. Capture the response error. +- [ ] **External-session visibility.** With Claude Code CLI sessions present in `~/.claude/sessions/` (or whatever the SDK uses on the smoke machine), they appear in the workbench session list alongside agent-host-created ones. If the smoke machine has none, create one out-of-band via `claude-code` CLI, then verify it surfaces. +- [ ] **Clean shutdown.** Kill the agent host process; logs show no unhandled rejection from a hung `Query` (there is no Query yet — but `shutdown()` should run its memoized promise to completion). +- [ ] **Empty-session GC (PR #313841).** Open new-chat against Claude, pick the folder, optionally pick a model, then close the new-chat view without sending a message. Within ~30 s the agent host log shows `GC: disposing empty unsubscribed session claude:/` and ClaudeAgent's `disposeSession` runs cleanly (no DB file written, no thrown errors, `_sessions` no longer contains the entry). +- [ ] Smoke artifacts saved under `/tmp/claude-phase5-smoke//`: `registration.log`, `disabled-gate.log`, `claude-session-uris.log`, `external-session.log`, `todo-phase6-error.png`, `shutdown.log`, `empty-session-gc.log`. + +### 7.7 PR readiness + +- [ ] PR title: `agentHost/claude: Phase 5 — session lifecycle`. +- [ ] PR description links to [roadmap.md](roadmap.md) Phase 5 and to this plan; notes that exit criteria are met. +- [ ] PR description lists the 7 implemented stubs + the 9 still-stubbed methods + their target phase as a table. +- [ ] PR description calls out the Phase-6 contract notes (worktree-extraction prerequisite, `canUseTool` consumes `permissionMode` + `Permissions` directly — see §8). +- [ ] PR is opened as draft until the build passes; promote when green. + +### 7.8 What to do if a step fails + +| Failure | Likely cause | First debugging step | +|---|---|---| +| `npm ls` shows native build steps | SDK version drifted to > 0.2.112 | Pin to exact `0.2.112` (no caret) in both root and `remote/` `package.json`. | +| `Cannot find module '@anthropic-ai/claude-agent-sdk'` from a utility process | Lazy import resolved against the wrong root | Verify `agentHostMain.ts` was bundled with the SDK in `node_modules` reachable from the utility process working directory. Check `agentHostServerMain.ts` similarly. | +| `valid-layers-check` fails | Imported a workbench/sessions symbol from `vs/platform/agentHost/` | Only `vs/base`, `vs/platform`, `vs/typings` allowed. The Claude permission-mode picker is workbench-side and must NOT be referenced from the platform layer. | +| Test 5 (corrupt-DB resilience) flakes | Used `Promise.all` instead of `await Promise.all(map(async ... try/catch))` | Inline-try/catch pattern from `agentService.ts:188-204`, NOT the bulk-`Promise.all` from `copilotAgent.ts:519-541`. | +| Test 10 (dispose ordering) fails | `dispose()` body called `_proxyHandle?.dispose()` before awaiting `shutdown()` | Reorder. The `await` matters — fire-and-forget breaks Phase 6. | +| `listSessions` test surfaces zero entries when SDK returns three | Filter inadvertently introduced (e.g. `if (!metadata) return undefined`) | Remove. SDK is source of truth; filter excludes external sessions. | +| Live smoke shows external Claude Code sessions but agent-host-created ones disappear after restart | Non-fork `createSession` is writing partial DB rows | Verify `openDatabase` is NOT called in the non-fork path. The "disappears after restart" symptom is the correct behavior for Phase 5 — pre-prompt drafts don't persist. | +| Live smoke shows `TODO: Phase 5` instead of `TODO: Phase 6` after first prompt | One of the seven Phase-5 methods still throws | Grep `TODO: Phase 5` in `claudeAgent.ts`; remaining hits are bugs. | + +## 8. Phase-6 contract notes (record now, implement then) + +These are decisions Phase 5 locks down so Phase 6 is a pure-additive change. They don't ship code in Phase 5 but they bind the schema and the lifecycle. + +**Permission-mode resolution helper (Phase 6 will add this method):** + +```ts +// src/vs/platform/agentHost/node/claude/claudeAgent.ts (Phase 6) +private _resolveClaudePermissionMode(sessionUri: URI): PermissionMode { + // Read the session's permissionMode value; fall back to schema default. + // canUseTool callback consumes BOTH this and the Permissions key directly — + // NO translation table, NO mapping. Single source of truth. + const mode = this._configurationService.getEffectiveValue( + sessionUri.toString(), claudeSessionSchema, ClaudeSessionConfigKey.PermissionMode); + return isPermissionMode(mode) ? mode : 'default'; +} +``` + +Phase 6's `canUseTool` reads `permissionMode` + `Permissions` directly. **NO `AutoApprove`-to-`permissionMode` translation helper.** Each provider owns its own permission semantics; the platform schema doesn't impose one. + +**Phase-6 prerequisite — extract `IAgentWorktreeService`:** + +`Isolation`, `Branch`, `BranchNameHint`, and `_resolveSessionProject` are about computing the cwd the agent runs in (possibly creating a git worktree, possibly resolving project info from cwd). All of this is provider-agnostic by nature. Today it lives inside `CopilotAgent`: + +- Worktree metadata: [copilot/copilotAgent.ts:1263-1325](../copilot/copilotAgent.ts#L1263-L1325) +- Project resolution: [copilot/copilotAgent.ts:521](../copilot/copilotAgent.ts#L521) (`_resolveSessionProject`) + +Claude needs the same semantic but advertising those keys without a backing implementation ships a UI lie. **Phase 6 (or a separate prerequisite PR) extracts `IAgentWorktreeService`** to the platform layer and updates both providers to consume it. Both providers then advertise `Isolation`/`Branch`/`BranchNameHint` in their schemas. + +**Cross-cutting principle to record in [CONTEXT.md](CONTEXT.md):** when Claude needs a "platform" capability that's actually living inside CopilotAgent, the right fix is to **lift it into the platform**, not duplicate it. Applies to worktrees, project resolution, and likely more as we cross into Phase 7+. + +## 9. Resolved decisions + +**Why is `listSessions` not gated on local-DB existence?** +The SDK is source of truth. CopilotAgent's pattern at `copilotAgent.ts:521-523` filters out sessions without local metadata, which has the side effect of hiding externally-created Claude Code sessions (CLI, Cursor, etc.). That's exactly the population the Phase-5 exit criterion calls out. The DB is overlay/cache only. + +**Why does non-fork `createSession` skip the DB write?** +Mirrors Claude Code's "no message → no session" semantic. Pre-prompt drafts are in-memory only; first `sendMessage` (Phase 6) writes the SDK session record. Avoids phantom DB rows when users open the picker, hesitate, and quit without sending. The cost — drafts evaporate on app close — is acceptable and matches the SDK's own behavior. + +**Why is the schema Claude-native (`permissionMode`) instead of platform-conforming (`AutoApprove` + `Mode`)?** +Decision **B5**. The `SessionConfigKey` doc-comment at [`sessionConfigKeys.ts:6-15`](../../common/sessionConfigKeys.ts) splits keys into platform-consumed (`AutoApprove`, `Permissions`, `Mode`) and client-convention (`Isolation`, `Branch`, `BranchNameHint`). But [`SessionPermissionManager`](../../common/sessionPermissions.ts#L117-L167) — the only reader of `AutoApprove` — fires only from Copilot SDK `pending_confirmation` signals. Claude SDK invokes `canUseTool` **directly** before each call, completely independent of platform gating. So `AutoApprove` is effectively a Copilot-private knob; Claude has no obligation to advertise it. The `permissionMode` enum (4 values) collapses what Copilot expresses as 2 axes (`AutoApprove` × `Mode`) into Claude's native single axis. Workbench UI is schema-driven and adapts automatically (§3.3.5). + +**Why is `turnIdMapping` consumed but not built by `ClaudeAgent.createSession`?** +[`AgentService.createSession`](../../common/agentService.ts#L252-L264) **already** builds `turnIdMapping` from the source session's turns BEFORE calling `provider.createSession(config)`. Providers are consumers, not authors. CopilotAgent's older inline-build pattern at `copilotAgent.ts:633` predates the centralized mapping; `ClaudeAgent` follows the new contract. + +**Why no `IClaudeSessionTranscriptStore` seam in Phase 5?** +2-of-3 reviewer consensus. The seam is dead code today — the only consumer would be `getSessionMessages`, which returns `[]` until Phase 13 anyway. Introducing the seam now means committing to an interface shape before we know what the hybrid (SDK + `sessionStore` alpha) implementation needs. Phase 13 (transcript reconstruction) is the natural place to introduce it. + +**Why is `shutdown` memoized?** +CopilotAgent's pattern at [`copilotAgent.ts:1057-1068`](../copilot/copilotAgent.ts#L1057-L1068). Multiple callers can race to shut down the agent during process exit (workbench window close + agent-host process signal). A memoized promise makes second/third calls cheap and correct. **The order `await shutdown(); _proxyHandle?.dispose();` is load-bearing for Phase 6** — Query subprocesses talk to the proxy and must die first. + +**Should `disposeSession` delete the DB?** +No. Phase 13 owns deletion semantics (full transcript management). `disposeSession` is a wrapper-removal, not a delete. External sessions surfaced via `listSessions` would re-appear on the next listing anyway, so DB deletion in Phase 5 would be both incomplete and confusing. diff --git a/src/vs/platform/agentHost/node/claude/phase6-plan.md b/src/vs/platform/agentHost/node/claude/phase6-plan.md new file mode 100644 index 0000000000000..4ee8abb2ec5cd --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/phase6-plan.md @@ -0,0 +1,944 @@ +# Phase 6 Implementation Plan — `ClaudeAgent` real `sendMessage` (single-turn, no tools) + +> **Handoff plan** — written to be executed by an agent with no prior conversation context. All file paths and line citations are verified against the workspace at synthesis time. Cross-reference [roadmap.md](./roadmap.md) before committing exact phase numbers. + +## 1. Goal + +Replace [claudeAgent.ts](claudeAgent.ts)'s `sendMessage` stub with a real implementation that streams a single assistant turn (no tool execution) from the Claude SDK back to the workbench client as `AgentSignal`s. Introduce the **provisional / materialize** lifecycle pattern that Phase 5 deliberately deferred: `createSession` returns immediately with `provisional: true`, the SDK subprocess fork happens lazily on the first `sendMessage`, and `onDidMaterializeSession` fires once the SDK init handshake completes. + +**Phase 6 deliverable:** the workbench's "smallest test stream" — `message_start → content_block_start → content_block_delta → content_block_stop → message_delta(usage) → message_stop → result` — flows end-to-end through `ClaudeProxyService → SDK subprocess → mapper → AgentSignal`. A user typing "hi" sees streamed assistant text appear incrementally. + +**Out of scope (deferred):** + +- **Fork** is **Phase 6.5** (separate stacked PR). The Phase-5 fork stub stays, the throw message updates from `TODO: Phase 6` to `TODO: Phase 6.5`. See §8 for the deferred decisions. +- Tools (Phase 7) — `canUseTool` returns `{ behavior: 'deny', message: '...' }` as a Phase-6 stub. The mapper has a defense-in-depth skip+warn for any `tool_use` block that leaks through. +- Edits (Phase 8), abort/steering/changeModel (Phase 9), client tools (Phase 10), customizations (Phase 11), subagents (Phase 12), restoration (Phase 13). + +**Exit criteria:** + +1. A workbench client creates a non-fork Claude session and the response carries `provisional: true`. No SDK subprocess has been forked. No `sessionAdded` notification has fired yet. +2. The first `sendMessage` materializes the session: SDK subprocess forks, init handshake completes, `onDidMaterializeSession` fires, `AgentService` dispatches the deferred `sessionAdded` notification. The user's prompt is delivered to the SDK. +3. Streaming `assistant` content appears in the workbench as `SessionResponsePart(Markdown)` followed by per-token `SessionDelta` signals. `result` triggers `SessionUsage` then `SessionTurnComplete` in that order. +4. A second `sendMessage` on the same materialized session reuses the existing Query (no second `startup()` call). `_isResumed` flips to `true` after the first `system:init`. +5. Disposing a materialized session aborts the SDK subprocess cleanly (no orphan processes). Disposing a still-provisional session is a cheap map removal. +6. `createSession({ fork })` throws `TODO: Phase 6.5`. +7. The proxy-backed integration test (real `ClaudeProxyService` + real `@anthropic-ai/claude-agent-sdk` + stubbed `ICopilotApiService`) passes end-to-end against a canned Anthropic stream. + +## 2. Files to create / modify + +| Action | File | Purpose | +|---|---|---| +| **Modify** | [claudeAgentSdkService.ts](claudeAgentSdkService.ts) | Add `startup({ options }): Promise` to `IClaudeSdkBindings` and `IClaudeAgentSdkService`. Phase-5 surface (`listSessions`) preserved. (`getSessionMessages` and `forkSession` are added in Phase 6.5, NOT Phase 6.) | +| **Major rewrite** | [claudeAgentSession.ts](claudeAgentSession.ts) | Phase-5 minimum (~30 lines) → Phase-6 Query owner (~300 lines): `_query: Query`, `_abortController: AbortController`, prompt iterable (`_createPromptIterable`), `_pendingPromptDeferred: DeferredPromise`, `_inFlightRequests: QueuedRequest[]`, `_isResumed: boolean`, `_currentBlockParts: Map`, `_fatalError: Error \| undefined`. Methods: `send`, `_processMessages`, `dispose`. | +| **Modify** | [claudeAgent.ts](claudeAgent.ts) | Add `_provisionalSessions: Map`, `_onDidMaterializeSession: Emitter`, `_sessionSequencer: SequencerByKey` (separate from Phase-5's `_disposeSequencer`). Add constructor dependency `@IAgentHostGitService` (resolved as `_gitService`) for `projectFromCopilotContext` lookups during `createSession`. Add helper imports: `rgPath` from `@vscode/ripgrep`, `delimiter` from `../../../../base/common/path.js`. Replace `sendMessage` stub. Make non-fork `createSession` return `provisional: true`. Add `_materializeProvisional`. Update fork branch error: `TODO: Phase 6` → `TODO: Phase 6.5`. Extend `shutdown()` to drain `_provisionalSessions` before the existing `_sessions` drain. | +| **Create** | [claudeMapSessionEvents.ts](claudeMapSessionEvents.ts) | Pure helper: `SDKMessage → AgentSignal[]`. Markdown/reasoning part allocation. Defense-in-depth skip+warn for `tool_use`. Mirrors Copilot's `mapSessionEvents.ts`. | +| **Create** | [claudePromptResolver.ts](claudePromptResolver.ts) | Pure helper: `(prompt: string, attachments?: IAgentAttachment[]) → Anthropic.ContentBlockParam[]`. Builds `` block for file/selection references. | +| **Modify** | [/package.json](../../../../../../package.json) | No version change — `@anthropic-ai/claude-agent-sdk@0.2.112` already pinned by Phase 5. | +| **Modify** | [../../test/node/claudeAgent.test.ts](../../test/node/claudeAgent.test.ts) | Extend `FakeClaudeAgentSdkService` with `startup()`, `nextQueryMessages`, `queryAdvance`, `capturedStartupOptions`, `startupRejection`. Add `FakeWarmQuery` and `FakeQuery` helpers. Add the 15 Phase-6 unit cases in §5. | +| **Create** | [../../test/node/claudeAgent.integration.test.ts](../../test/node/claudeAgent.integration.test.ts) | Single proxy-backed integration test (real `ClaudeProxyService` + real SDK + stubbed `ICopilotApiService`). Roadmap explicit requirement ([roadmap.md L532](roadmap.md#L532)). | + +## 3. Implementation spec + +### 3.1 SDK service: add `startup()` + +Phase 5's `IClaudeSdkBindings` and `IClaudeAgentSdkService` expose **only** `listSessions`. Phase 6 adds the **session-creation** surface, `startup()`. Phase 6.5 will later add `getSessionMessages` and `forkSession`. Per the SDK at [sdk.d.ts:4550](../../../../../../node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts) `startup({ options, initializeTimeoutMs? })` forks the subprocess and **completes the init handshake** before returning a `WarmQuery`. Then `warm.query(promptIterable)` binds the prompt and returns a `Query`. This is a strict upgrade over the production extension's `query({ prompt, options })` flow at [`claudeCodeAgent.ts:487`](../../../../../../extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts#L487) — `startup()` was added after the extension shipped, and agent host is greenfield. The split lets us **fire `onDidMaterializeSession` only after the subprocess fork + init succeeded**, avoiding any phantom-session class of bug. + +```ts +// claudeAgentSdkService.ts (extension) + +export interface IClaudeSdkBindings { + listSessions(options?: ListSessionsOptions): Promise; + /** + * Pre-warms the SDK subprocess and runs the init handshake. Returns a + * `WarmQuery` whose `.query(promptIterable)` binds the prompt iterable + * and returns a streaming `Query`. Aborting `options.abortController` + * either rejects this promise (if init is in flight) or causes the + * resulting Query to clean up resources (sdk.d.ts:982). + */ + startup(params: { options: Options; initializeTimeoutMs?: number }): Promise; +} + +export interface IClaudeAgentSdkService { + readonly _serviceBrand: undefined; + listSessions(): Promise; + startup(params: { options: Options; initializeTimeoutMs?: number }): Promise; + // getSessionMessages + forkSession added in Phase 6.5 +} +``` + +`ClaudeAgentSdkService.startup` is a thin pass-through to the lazily-imported SDK module — `await this._loadSdk()` then `sdk.startup(params)`. No additional state. + +### 3.2 `IClaudeProvisionalSession` + provisional state on `ClaudeAgent` + +Mirrors CopilotAgent's `IProvisionalSession` at [`copilotAgent.ts:67-82`](../copilot/copilotAgent.ts#L67-L82) plus an `AbortController` for the Q8 shutdown-during-materialize race. + +```ts +// claudeAgent.ts (additions) + +interface IClaudeProvisionalSession { + readonly sessionId: string; + readonly sessionUri: URI; + readonly workingDirectory: URI; + /** + * Per-session AbortController. Wired into `Options.abortController` + * during materialization. On materialize success, ownership transfers + * to the new `ClaudeAgentSession` (which registers + * `toDisposable(() => abortController.abort())`). Until then, `shutdown` + * iterates `_provisionalSessions` and calls `abort()` directly to + * unblock any in-flight `await sdk.startup()`. See §3.4. + */ + readonly abortController: AbortController; + /** Eagerly resolved at create time so the summary renders. */ + readonly project: IAgentSessionProjectInfo | undefined; +} + +private readonly _provisionalSessions = new Map(); + +private readonly _onDidMaterializeSession = this._register(new Emitter()); +readonly onDidMaterializeSession = this._onDidMaterializeSession.event; + +/** + * Per-session sequencer for first-message materialization and subsequent + * sends. SEPARATE from Phase-5's `_disposeSequencer` because they + * serialize different concerns: `_disposeSequencer` linearizes teardown, + * `_sessionSequencer` linearizes turn-driving. Mirrors CopilotAgent + * (`copilotAgent.ts:265`). + */ +private readonly _sessionSequencer = new SequencerByKey(); +``` + +`AgentService` already understands this protocol — see [`agentService.ts:154-160, 334-360`](../agentService.ts#L334-L360): +- If the agent provider's `IAgentCreateSessionResult.provisional === true`, AgentService creates the session in the state manager **with `emitNotification: false`**, defers `sessionAdded`, and skips the `SessionReady` lifecycle dispatch. +- When `IAgent.onDidMaterializeSession` fires, AgentService calls `dispatchedSessionAdded(...)` and then `dispatchServerAction({ type: ActionType.SessionReady, ... })`. + +ClaudeAgent only needs to honor the contract: return `provisional: true` from non-fork `createSession`, and fire `onDidMaterializeSession` from `_materializeProvisional`. + +### 3.3 `createSession` — return `provisional: true` + +Phase-5's non-fork path eagerly creates a `ClaudeAgentSession` wrapper and stores it in `_sessions`. Phase 6 replaces that with a provisional record. Fork still throws — message updated. + +**New constructor dependency.** `ClaudeAgent`'s Phase-5 constructor at [`claudeAgent.ts:136-141`](claudeAgent.ts#L136-L141) does NOT inject git context. Phase 6 adds `@IAgentHostGitService` (resolved as `private readonly _gitService: IAgentHostGitService`, imported from `'../agentHostGitService.js'`) so `createSession` can call `projectFromCopilotContext(...)` (imported from `'../copilot/copilotGitProject.js'`). Mirrors CopilotAgent at [`copilotAgent.ts:843`](../copilot/copilotAgent.ts#L843). Test fakes use `createNoopGitService()` from `'../../test/common/sessionTestHelpers.js'`. + +```ts +async createSession(config: IAgentCreateSessionConfig = {}): Promise { + if (config.fork) { + // Fork moved to Phase 6.5: requires translating `config.fork.turnId` + // (a protocol turn ID) to an SDK message UUID via `sdk.getSessionMessages`. + // See phase6-plan.md §8. + throw new Error('TODO: Phase 6.5: fork requires message-UUID lookup via sdk.getSessionMessages'); + } + + // Non-fork path: provisional. NO subprocess fork, NO worktree, NO DB write. + // Materialization happens in `_materializeProvisional` on the first + // `sendMessage`. AgentService defers `sessionAdded` until then. + const sessionId = config.session ? AgentSession.id(config.session) : generateUuid(); + const sessionUri = AgentSession.uri(this.id, sessionId); + + // Idempotent re-creates (workbench reconnect): if the session is already + // materialized OR already provisional, return the same URI. Mirrors + // CopilotAgent (`copilotAgent.ts:732-746`). We deliberately do NOT + // overwrite the existing provisional record — a re-create payload from + // a fresh connection would clobber the AbortController. + if (this._sessions.has(sessionId)) { + return { session: sessionUri, workingDirectory: config.workingDirectory }; + } + if (this._provisionalSessions.has(sessionId)) { + return { session: sessionUri, workingDirectory: config.workingDirectory, provisional: true }; + } + + if (!config.workingDirectory) { + throw new Error(`createSession: workingDirectory is required for new Claude sessions`); + } + + const project = await projectFromCopilotContext( + { cwd: config.workingDirectory.fsPath }, + this._gitService, + ); + + this._provisionalSessions.set(sessionId, { + sessionId, + sessionUri, + workingDirectory: config.workingDirectory, + abortController: new AbortController(), + project, + }); + + return { + session: sessionUri, + workingDirectory: config.workingDirectory, + provisional: true, + ...(project ? { project } : {}), + }; +} +``` + +**Phase-5 invariants Phase 6 preserves:** +- Non-fork `createSession` does NOT call `ISessionDataService.openDatabase` / `tryOpenDatabase`. (`_provisionalSessions` is in-memory only.) +- Non-fork `createSession` does NOT call any `IClaudeAgentSdkService` method. (Materialize is deferred.) + +**New invariants:** +- Non-fork `createSession` returns `provisional: true` and does NOT add an entry to `_sessions`. +- A duplicate `createSession` for a still-provisional URI returns the same URI without overwriting the existing provisional record. + +### 3.4 `_materializeProvisional` + +Promotes a `IClaudeProvisionalSession` into a real `ClaudeAgentSession`. Called from `sendMessage` (§3.8) inside the `_sessionSequencer.queue(sessionId, ...)` block, so concurrent first sends serialize naturally. + +```ts +private async _materializeProvisional(sessionId: string): Promise { + const provisional = this._provisionalSessions.get(sessionId); + if (!provisional) { + throw new Error(`Cannot materialize unknown provisional session: ${sessionId}`); + } + + const proxyHandle = this._proxyHandle; + if (!proxyHandle) { + throw new Error('Claude proxy is not running; agent must be authenticated first'); + } + + const subprocessEnv = this._buildSubprocessEnv(); + // `proxyHandle.baseUrl` is the full URL (e.g. `http://127.0.0.1:54321`, + // no trailing slash). Source: `claudeProxyService.ts:44-49`. Do NOT + // try to read `proxyHandle.port`; it is not part of the contract. + // + // PATH composition: + // - `rgPath` (imported from `@vscode/ripgrep`) is the absolute path to + // the ripgrep BINARY. Use `path.dirname(rgPath)` for the directory. + // - `delimiter` (imported from `../../../../base/common/path.js`) is + // the PATH separator (`:` on macOS/Linux, `;` on Windows). Do NOT + // use `path.sep` (`/` or `\\`) — that would corrupt PATH on Windows. + // Mirrors CopilotAgent (`copilotAgent.ts:7, 17, 434-450`). + const settingsEnv = { + ANTHROPIC_BASE_URL: proxyHandle.baseUrl, + ANTHROPIC_AUTH_TOKEN: `${proxyHandle.nonce}.${sessionId}`, + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + USE_BUILTIN_RIPGREP: '0', + PATH: `${dirname(rgPath)}${delimiter}${process.env.PATH ?? ''}`, + }; + + const options: Options = { + cwd: provisional.workingDirectory.fsPath, + executable: process.execPath as 'node', + env: subprocessEnv, + abortController: provisional.abortController, + allowDangerouslySkipPermissions: true, + canUseTool: async (_name, _input) => ({ + behavior: 'deny', + message: 'Tools are not yet enabled for this session (Phase 6).', + }), + disallowedTools: ['WebSearch'], + includeHookEvents: true, + includePartialMessages: true, // per-token streaming + permissionMode: 'default', + sessionId, // first run: new SDK session + settingSources: ['user', 'project', 'local'], + settings: { env: settingsEnv }, + systemPrompt: { type: 'preset', preset: 'claude_code' }, + stderr: data => this._logService.error(`[Claude SDK stderr] ${data}`), + }; + + const warm = await this._sdkService.startup({ options }); + + // Q8 belt-and-suspenders: the SDK's comment guarantees abort cleanup + // (sdk.d.ts:982), but if `startup()` resolved despite a racing abort, + // dispose the WarmQuery and surface cancellation. The agent has been + // shutting down while we awaited; do NOT materialize. + if (provisional.abortController.signal.aborted) { + await warm[Symbol.asyncDispose](); + throw new CancellationError(); + } + + const session = this._createSessionWrapper( + sessionId, + provisional.sessionUri, + provisional.workingDirectory, + warm, + provisional.abortController, + ); + + // Persist customization-directory metadata BEFORE firing the + // materialize event. The `IAgentMaterializeSessionEvent` contract + // (agentService.ts:142-147 + agentService.ts:393-395 in `node/`) + // says the agent has "persisted on-disk metadata" by the time the + // event fires. AgentService relies on this to atomically dispatch + // `sessionAdded` + `SessionReady`; firing before the write would + // race those notifications past durable state. CopilotAgent at + // `copilotAgent.ts:843-848` awaits `_storeSessionMetadata` before + // firing — Phase 6 mirrors that ordering. + // + // On persistence failure: dispose the wrapper (which aborts the + // SDK subprocess), keep the provisional record removed, and re-throw. + // Treating this as fatal avoids silent half-persisted state. The + // user sees a `SessionError` and the session never enters `_sessions`. + try { + await this._writeCustomizationDirectory(provisional.sessionUri, provisional.workingDirectory); + } catch (err) { + session.dispose(); + this._provisionalSessions.delete(sessionId); + this._logService.error(`[Claude] Failed to persist customization directory; aborting materialize`, err); + throw err; + } + + this._sessions.set(sessionId, session); + this._provisionalSessions.delete(sessionId); + + this._onDidMaterializeSession.fire({ + session: provisional.sessionUri, + workingDirectory: provisional.workingDirectory, + project: provisional.project, + }); + + return session; +} + +private _buildSubprocessEnv(): Record { + const env: Record = { + ELECTRON_RUN_AS_NODE: '1', + NODE_OPTIONS: undefined, + ANTHROPIC_API_KEY: undefined, + }; + for (const key of Object.keys(process.env)) { + if (key === 'ELECTRON_RUN_AS_NODE') { continue; } + if (key.startsWith('VSCODE_') || key.startsWith('ELECTRON_')) { + env[key] = undefined; + } + } + return env; +} +``` + +**`Options.env` contract** (sdk.d.ts:1075-1078): "Merged on top of `process.env` — entries here override... Set a key to `undefined` to remove an inherited variable." Mirrors CopilotAgent's strip pattern at [`copilotAgent.ts:434-450`](../copilot/copilotAgent.ts#L434-L450). + +**Why agent host strips env when the production extension doesn't**: extension runs in EH (already Electron-as-node, `NODE_OPTIONS` configured for EH); agent host runs in a utility process spawned from main, subprocess env state isn't pre-conditioned. + +`_createSessionWrapper` is updated to take the `WarmQuery` and the `AbortController` (vs the Phase-5 minimal signature). Tests override this hook to inject a recording subclass. + +### 3.5 `ClaudeAgentSession` — Query owner (~300 lines) + +Major rewrite from Phase 5's 30-line minimum. Owns the SDK Query, the per-session AbortController, the prompt iterable, and the message processing loop. + +```ts +// claudeAgentSession.ts (Phase 6) + +interface QueuedRequest { + readonly prompt: SDKUserMessage; + readonly deferred: DeferredPromise; + /** + * Required (non-optional). The agent's `sendMessage(...)` interface accepts + * `turnId?: string` (`agentService.ts:424`), but `AgentSideEffects` always + * supplies one (`agentSideEffects.ts:704`, `:939`). Phase 6's `ClaudeAgent.sendMessage` + * generates a UUID via `generateUuid()` if the caller omitted it, before + * forwarding to `entry.send()`. The mapper depends on `turnId: string` to + * populate `SessionDeltaAction.turnId` etc. (`actions.ts:233-258, 460-465, 521-526`). + */ + readonly turnId: string; +} + +export class ClaudeAgentSession extends Disposable { + /** SDK Query handle. Null until first `send()` binds the prompt iterable. */ + private _query: Query | undefined; + + /** Wakes the prompt iterable's `next()` when a new prompt arrives or on abort. */ + private _pendingPromptDeferred = new DeferredPromise(); + + /** FIFO of in-flight requests. Length ≤ 1 in Phase 6 due to `_sessionSequencer`. */ + private _inFlightRequests: QueuedRequest[] = []; + + /** Prompts pushed by `send()`, drained by the prompt iterable. */ + private _queuedPrompts: SDKUserMessage[] = []; + + /** Flips true after the first `system:init` SDKMessage; controls `sessionId` vs `resume` on re-options. */ + private _isResumed = false; + + /** content_block index → response part id. Cleared on `message_start`. */ + private readonly _currentBlockParts = new Map(); + + /** Mapper state passed to `mapSDKMessageToAgentSignals`. Held here so the loop can clear it on errors. */ + private readonly _mapperState: IClaudeMapperState = { currentBlockParts: this._currentBlockParts }; + + /** + * Set by `_processMessages` if the SDK iterator throws or ends without + * `result`. Once set, every subsequent `send()` rejects immediately + * with this error rather than parking on `_pendingPromptDeferred.p` + * (which would hang forever — the consumer loop is dead). Cleared by + * dispose, never recovered: post-fatal-error sessions are dead until + * the caller disposes them and creates a new session. Phase 6 has no + * teardown+recreate flow so this is a terminal state. + */ + private _fatalError: Error | undefined; + + constructor( + readonly sessionId: string, + readonly sessionUri: URI, + readonly workingDirectory: URI | undefined, + private readonly _warm: WarmQuery, + private readonly _abortController: AbortController, + private readonly _onDidSessionProgress: Emitter, + @ILogService private readonly _logService: ILogService, + ) { + super(); + // Dispose chain → abort → SDK cleanup (sdk.d.ts:982). + this._register(toDisposable(() => this._abortController.abort())); + // Wake parked iterator on abort so it can return `{ done: true }`. + this._abortController.signal.addEventListener('abort', () => { + this._pendingPromptDeferred.complete(); + }, { once: true }); + // The WarmQuery itself owns disposable resources too. + this._register(toDisposable(() => { + void this._warm[Symbol.asyncDispose]().catch(err => + this._logService.warn(`[ClaudeAgentSession] WarmQuery dispose failed: ${err}`)); + })); + } + + /** + * Push a prompt onto the queue and await the turn's completion (the + * `result` SDKMessage). Throws `CancellationError` if the session has + * already been aborted. Throws the stored `_fatalError` if the + * background `_processMessages` loop has died (S7: prevents silent + * infinite hangs on retry-after-fatal). The first call also binds the + * prompt iterable to the WarmQuery and kicks off `_processMessages`. + */ + async send(prompt: SDKUserMessage, turnId: string): Promise { + if (this._abortController.signal.aborted) { + throw new CancellationError(); + } + if (this._fatalError) { + // Loop is dead. Reject immediately rather than parking on a + // deferred no consumer will ever pop. Caller must dispose and + // recreate the session to recover. + throw this._fatalError; + } + if (!this._query) { + this._query = this._warm.query(this._createPromptIterable()); + // Fire-and-forget: errors propagate via QueuedRequest.deferred, + // and any post-loop crash is captured into `_fatalError`. + void this._processMessages().catch(err => + this._logService.error(`[ClaudeAgentSession] _processMessages crashed: ${err}`)); + } + const deferred = new DeferredPromise(); + this._inFlightRequests.push({ prompt, deferred, turnId }); + this._queuedPrompts.push(prompt); + this._pendingPromptDeferred.complete(); + return deferred.p; + } + + private _createPromptIterable(): AsyncIterable { + return { + [Symbol.asyncIterator]: () => ({ + next: async () => { + while (this._queuedPrompts.length === 0) { + if (this._abortController.signal.aborted) { + return { done: true, value: undefined }; + } + await this._pendingPromptDeferred.p; + this._pendingPromptDeferred = new DeferredPromise(); + } + return { done: false, value: this._queuedPrompts.shift()! }; + }, + }), + }; + } + + private async _processMessages(): Promise { + try { + for await (const message of this._query!) { + if (this._abortController.signal.aborted) { + throw new CancellationError(); + } + if (message.type === 'system' && (message as SDKSystemMessage).subtype === 'init' && !this._isResumed) { + this._isResumed = true; + } + // Mapper needs the current turn's `turnId` to populate + // `SessionAction.turnId` (actions.ts:238, 256, 465, 526). + // Phase 6 always has exactly one in-flight request when + // streaming is active; reading the head element is safe. + const turnId = this._inFlightRequests[0]?.turnId; + if (turnId !== undefined) { + try { + const signals = mapSDKMessageToAgentSignals( + message, + this.sessionUri, + turnId, + this._mapperState, + this._logService, + ); + for (const signal of signals) { + this._onDidSessionProgress.fire(signal); + } + } catch (mapperErr) { + // Q12 rule 1: defense-in-depth. Don't kill the turn on a + // single malformed SDK message. + this._logService.warn(`[ClaudeAgentSession] mapper threw, skipping message: ${mapperErr}`); + } + } + if (message.type === 'result') { + if ((message as SDKResultMessage).is_error) { + this._logService.warn(`[ClaudeAgentSession] result.is_error: ${(message as SDKResultMessage).error_during_execution ?? 'unknown'}`); + } + const completed = this._inFlightRequests.shift(); + completed?.deferred.complete(); + } + } + // S6: if the SDK iterator closed cleanly while aborted (sdk.d.ts:982 + // says "stop and clean up resources" — a graceful close is allowed), + // surface as `CancellationError`, not a generic "ended without result" + // failure. Phase 9's cancellation discrimination (§8.3) depends on + // this being a `CancellationError` instance. + if (this._abortController.signal.aborted) { + throw new CancellationError(); + } + // Generator ended without `result` for any in-flight request. + throw new Error('Claude SDK stream ended without result'); + } catch (err) { + // S7: latch the failure so subsequent `send()` calls reject + // immediately. Without this, a retry pushes a prompt into + // `_queuedPrompts` and parks on `_pendingPromptDeferred.p` + // — the loop is dead, the prompt never drains, hang forever. + this._fatalError = err instanceof Error ? err : new Error(String(err)); + for (const req of this._inFlightRequests) { + if (!req.deferred.isSettled) { + req.deferred.error(err); + } + } + this._inFlightRequests = []; + throw err; + } + } +} +``` + +**Why a queue of length ≤ 1 instead of a single `_currentRequest`**: `_sessionSequencer` (§3.8) guarantees serialized first-call materialization and serialized subsequent sends, so the queue is currently always length ≤ 1. The queue shape is preserved because Phase 7+ (tools) introduces intra-turn waits that may need short bursts of >1 in-flight, and we don't want to refactor the loop later. + +**Why the AbortController drives prompt-iterable termination** (Q9): the controller is already (a) the SDK's cancellation contract, (b) the dispose-chain endpoint via `toDisposable(() => abort())`, (c) the shutdown-cascade signal. Reusing it as the iterator's "done" condition keeps the entire session lifecycle on a single observable signal. No bespoke `_isDisposed` flag. + +### 3.6 `claudeMapSessionEvents.ts` — pure helper + +Mirrors Copilot's `mapSessionEvents.ts`. Pure function that takes one `SDKMessage` plus the `sessionUri`, the active `turnId`, and mutable mapper state, and returns zero or more `AgentSignal`s. Pure-function testability is the reason it's its own module instead of a private method on the session class. + +```ts +// claudeMapSessionEvents.ts + +export interface IClaudeMapperState { + /** content_block index → response part id. Owned by the session, cleared on message_start. */ + readonly currentBlockParts: Map; +} + +/** + * Map one SDK message to zero or more agent signals. + * + * `session` is the session URI used for the `IAgentActionSignal.session` + * envelope (`agentService.ts:293-298`) and for the `SessionAction.session` + * field on every emitted action (`actions.ts:233-258, 460-465, 521-526`). + * + * `turnId` is the protocol turn id originating from the client-driven + * `SessionTurnStarted` action (`agentSideEffects.ts:670` case handler). + * Every emitted action requires it; the session reads it from the head + * of `_inFlightRequests` per Phase-6's single-in-flight invariant. + * + * Phase 6 emits: + * - `SessionResponsePart(Markdown)` on `content_block_start` with text type + * - `SessionResponsePart(Reasoning)` on `content_block_start` with thinking type + * - `SessionDelta` on `content_block_delta` with text_delta + * - `SessionReasoning` on `content_block_delta` with thinking_delta + * - `SessionUsage` on `result` (or `message_delta` if usage is set) + * - `SessionTurnComplete` on `result` + * + * Phase 6 deliberately does NOT emit `SessionTurnStarted` — that's + * `AgentSideEffects`' job (`agentSideEffects.ts:484` for the dispatch, + * `:670` for the case handler that calls `agent.sendMessage`). And + * `SessionError` is dispatched by `AgentSideEffects.catch()` chain on + * `sendMessage` (`agentSideEffects.ts:704`). + * + * Reducer ordering invariant: the protocol reducer at `actions.ts:233, 460` + * REQUIRES `SessionResponsePart` to precede any `SessionDelta` / + * `SessionReasoning` for that part id. The mapper allocates the part + * before the first delta; tests assert ordering, not just presence. + */ +export function mapSDKMessageToAgentSignals( + message: SDKMessage, + session: URI, + turnId: string, + state: IClaudeMapperState, + logService: ILogService, +): AgentSignal[] { + // ... (see §4 for the full table) +} +``` + +The body implements the full Q7 mapping table from the planning conversation. Trace-log + skip for unhandled types so unexpected SDK additions don't throw. + +### 3.7 `claudePromptResolver.ts` — pure helper + +Builds the `Anthropic.ContentBlockParam[]` from a prompt string + serialized `IAgentAttachment[]`. Pure, no I/O. + +```ts +// claudePromptResolver.ts + +export function resolvePromptToContentBlocks( + prompt: string, + attachments?: readonly IAgentAttachment[], +): Anthropic.ContentBlockParam[] { + const blocks: Anthropic.ContentBlockParam[] = [{ type: 'text', text: prompt }]; + if (!attachments?.length) { + return blocks; + } + const refLines: string[] = []; + for (const att of attachments) { + switch (att.type) { + case AttachmentType.File: + case AttachmentType.Directory: + refLines.push(`- ${uriToString(att.uri)}`); + break; + case AttachmentType.Selection: { + const line = att.selection ? `:${att.selection.start.line + 1}` : ''; + refLines.push(`- ${uriToString(att.uri)}${line}`); + if (att.text) { + refLines.push('```'); + refLines.push(att.text); + refLines.push('```'); + } + break; + } + } + } + blocks.push({ + type: 'text', + text: '\nThe user provided the following references:\n' + + refLines.join('\n') + + '\n\nIMPORTANT: this context may or may not be relevant to your tasks. ' + + 'You should not respond to this context unless it is highly relevant to your task.\n' + + '', + }); + return blocks; +} + +function uriToString(uri: URI): string { + return uri.scheme === 'file' ? uri.fsPath : uri.toString(); +} +``` + +**Extension-ahead-of-protocol notes** (record but not Phase 6 work): the production extension at [`claudePromptResolver.ts`](../../../../../../extensions/copilot/src/extension/chatSessions/claude/node/claudePromptResolver.ts) handles inline range substitution and binary-image extraction. Protocol's `IAgentAttachment` carries neither today. When images land, follow the extension's `image` content block path; when inline ranges land, port the descending-sort replacement loop. + +**Selection branch is dead-code in Phase 6** (S4 from review). `IAgentAttachment` (`agentService.ts:243-254`) carries `text` and `selection` for the `Selection` attachment type, but `AgentSideEffects` strips them at the protocol → agent boundary (`agentSideEffects.ts:699-703` for live send and `:934-938` for queued send) — the agent receives only `{ type, uri, displayName }`. The `Selection` switch case in `resolvePromptToContentBlocks` therefore exists for forward-compat (mirroring the production extension's shape) but never executes in Phase 6. A future phase that expands `AgentSideEffects` to forward `text` + `selection` activates it without resolver changes. Phase 6 must NOT touch `agentSideEffects.ts` to enable selection rendering — that scope expansion is deferred. + +### 3.8 `sendMessage` — sequencer + materialize-first + entry.send + +```ts +async sendMessage( + session: URI, + prompt: string, + attachments?: IAgentAttachment[], + turnId?: string, +): Promise { + const sessionId = AgentSession.id(session); + // `IAgent.sendMessage` declares `turnId?` (agentService.ts:424) but + // every production caller in `AgentSideEffects` supplies one + // (`agentSideEffects.ts:704, :939`). Generate a fallback so the + // session-side `QueuedRequest.turnId: string` invariant holds even + // if a hypothetical future caller forgets it; tests can rely on + // their explicit value being passed through. + const effectiveTurnId = turnId ?? generateUuid(); + return this._sessionSequencer.queue(sessionId, async () => { + let entry = this._sessions.get(sessionId); + if (!entry) { + if (this._provisionalSessions.has(sessionId)) { + entry = await this._materializeProvisional(sessionId); + } else { + throw new Error(`Cannot send to unknown session: ${sessionId}`); + } + } + + const contentBlocks = resolvePromptToContentBlocks(prompt, attachments); + const sdkPrompt: SDKUserMessage = { + type: 'user', + message: { role: 'user', content: contentBlocks }, + session_id: sessionId, + parent_tool_use_id: null, + }; + + await entry.send(sdkPrompt, effectiveTurnId); + }); +} +``` + +**Sequencer scope**: the `queue(sessionId, ...)` block holds the sequencer through both materialize AND `entry.send`. This guarantees: (a) two concurrent first-message calls serialize into one materialization plus two ordered sends, (b) a `disposeSession` racing a first send reaches the dispose-sequencer eventually but the in-flight materialize completes its own work first, (c) Phase 7+ intra-turn waits don't deadlock because they happen inside `entry.send` after the sequencer has been entered (sequencer is per-key, not global). + +**`entry.send` returns the deferred** for the in-flight turn, so `sendMessage` only resolves when `result` arrives. AgentSideEffects' `.catch()` at [`agentSideEffects.ts:704`](../agentSideEffects.ts#L704) sees errors and dispatches `SessionError`. + +### 3.9 `shutdown` — drain provisional then sessions + +Phase 5's `shutdown` already serializes per-session teardown via `_disposeSequencer`. Phase 6 prepends a provisional drain so any in-flight `await sdk.startup()` aborts cleanly. + +```ts +shutdown(): Promise { + return this._shutdownPromise ??= (async () => { + // Q8: cancel any provisional sessions mid-materialize. Their + // AbortControllers are wired into Options.abortController, so + // aborting unblocks any in-flight `await sdk.startup()`. + for (const provisional of this._provisionalSessions.values()) { + provisional.abortController.abort(); + } + this._provisionalSessions.clear(); + + // Existing Phase-5 drain. Each ClaudeAgentSession registers + // `toDisposable(() => abortController.abort())`, so disposing + // them aborts their SDK Query. + const sessionIds = [...this._sessions.keys()]; + await Promise.all(sessionIds.map(sessionId => + this._disposeSequencer.queue(sessionId, async () => { + this._sessions.deleteAndDispose(sessionId); + }), + )); + })(); +} +``` + +`disposeSession(uri)` for a still-provisional session is a new branch: + +```ts +disposeSession(session: URI): Promise { + const sessionId = AgentSession.id(session); + return this._disposeSequencer.queue(sessionId, async () => { + const provisional = this._provisionalSessions.get(sessionId); + if (provisional) { + provisional.abortController.abort(); + this._provisionalSessions.delete(sessionId); + return; + } + this._sessions.deleteAndDispose(sessionId); + }); +} +``` + +## 4. SDK message → `AgentSignal` mapping (Phase 6 table) + +`Options.includePartialMessages: true` means we receive raw `stream_event` SDKMessages for true per-token streaming. `assistant` SDKMessages still arrive but text content is NOT re-emitted (already streamed via `stream_event`). This is a UX upgrade over the production extension which doesn't set the flag. + +| SDKMessage | AgentSignal(s) / behavior | +|---|---| +| `system` (subtype `init`) | Set `_isResumed = true`. No signal. | +| `stream_event` → `message_start` | Clear `_currentBlockParts`. No signal. | +| `stream_event` → `content_block_start` (text) | Allocate new partId, emit `SessionResponsePart(Markdown)`. Store `currentBlockParts.set(event.index, partId)`. | +| `stream_event` → `content_block_start` (thinking) | Allocate new partId, emit `SessionResponsePart(Reasoning)`. Store. | +| `stream_event` → `content_block_start` (tool_use) | **Skip + warn.** No partId allocated. Defense-in-depth — `canUseTool: deny` should prevent this. | +| `stream_event` → `content_block_delta` (text_delta) | Emit `SessionDelta(currentBlockParts.get(event.index), event.delta.text)`. | +| `stream_event` → `content_block_delta` (thinking_delta) | Emit `SessionReasoning(partId, event.delta.thinking)`. | +| `stream_event` → `content_block_delta` (input_json_delta) | No-op (tool input parameters; out of Phase 6 scope). | +| `stream_event` → `content_block_stop` | `currentBlockParts.delete(event.index)`. No signal. | +| `stream_event` → `message_delta` | If `usage` present, emit `SessionUsage`. | +| `stream_event` → `message_stop` | No signal (turn-complete is driven by `result`, not stream_event). | +| `assistant` (whole message) | Used ONLY for metadata: error-field log, defense-in-depth tool_use verification. Text content NOT re-emitted. | +| `result` | Emit `SessionUsage` (if not already emitted via message_delta) then `SessionTurnComplete`. | +| `system` (subtype `compact_boundary`) | No-op (Phase 6 has no context management). | +| `user` (tool_result) | No-op (Phase 7 territory). | +| Other | Trace-log + skip. | + +**Reducer ordering invariant** (`actions.ts:233, 460`): `SessionResponsePart` MUST precede any `SessionDelta` / `SessionReasoning` for that part id. The mapper allocates parts before deltas; tests assert ordering not just presence (Tests 6, 7 in §5). + +## 5. Test cases + +`ensureNoDisposablesAreLeakedInTestSuite()` stays at the top of the suite (preserved from Phase 5). + +### 5.1 Unit tests (15 new cases) + +1. **`createSession` non-fork → `provisional: true`.** Result has `provisional: true`. `_provisionalSessions` has one entry. `_sessions` is empty. SDK was NOT called (`startupCallCount === 0`, `listSessionsCallCount === 0`). Database was NOT opened (`openDatabaseCallCount === 0`). +2. **`createSession` with `config.fork` → throws "TODO: Phase 6.5".** No side effects. +3. **First `sendMessage` on a provisional session → materializes.** `onDidMaterializeSession` fires exactly once. `startupCallCount === 1`. After completion, `_sessions` has the entry, `_provisionalSessions` is empty. +4. **Materialize event payload shape.** `{ session: , workingDirectory: , project: undefined }` (project field is optional and tests don't set up gitService). +5. **Two `sendMessage` calls on the same session → reuses Query.** `startupCallCount === 1` after both. Both deferreds complete on their respective `result` messages. +6. **Assistant text block → `SessionResponsePart(Markdown)` precedes `SessionDelta`.** Capture all signals from `_onDidSessionProgress`. Assert the first `SessionDelta` for partId X is preceded by exactly one `SessionResponsePart(kind=Markdown, partId=X)` for the same X. +7. **Assistant thinking block → `SessionResponsePart(Reasoning)` precedes `SessionReasoning`.** Same shape as test 6, kind=Reasoning. +8. **`result` SDKMessage → `SessionUsage` then `SessionTurnComplete` in that order.** Snapshot the suffix of the signal sequence after the last delta. +9. **Multiple text blocks in one assistant message → each gets its own part allocation.** Two `content_block_start(text)` events at indices 0 and 1. Assert two distinct partIds were allocated, deltas routed correctly. +10. **`_isResumed` flips on first `system:init`.** First `sendMessage` produces a session whose `_isResumed === true` after the init message. (Asserted via a getter exposed for test, OR by triggering a teardown+recreate flow that asserts `Options.resume === sessionId` on the second `startup()` — Phase 6 doesn't have teardown+recreate yet, so the getter is acceptable.) +11. **Dispose materialized session → controller aborted; in-flight deferred rejects.** Set `queryAdvance` to block at index 3. Call `sendMessage` (returns pending promise). Call `disposeSession`. Resolve the blocker. Assert: (a) the pending sendMessage promise rejects, (b) `capturedStartupOptions[0].abortController.signal.aborted === true`, (c) `_sessions` no longer has the entry. +12. **Dispose provisional session → no SDK call; map removed.** Create provisional, call `disposeSession`. Assert `_provisionalSessions` is empty, `startupCallCount === 0`. +13. **Shutdown drain — two scenarios.** + - **(a) Only provisional**: create three sessions, none send. Call `shutdown()`. Assert `startupCallCount === 0`, `_provisionalSessions` is empty. + - **(b) Mixed provisional + materialized**: create three sessions, send on two (leaving one provisional). With `queryAdvance` blocking, call `shutdown()`. Assert all three deferreds resolve/reject (the two materialized reject with abort, the provisional one was never awaiting send), `_sessions` and `_provisionalSessions` both empty, controller of every entry was aborted. +14. **Mapper throws on a malformed `stream_event` → log + continue.** Inject a malformed message at index 2 via `nextQueryMessages`. Assert: warn was logged once, signals from indices 0, 1, 3, 4 emitted normally, turn completes via `result`. +15. **Attachment conversion (File / Directory only).** S4 from review: `text` and `selection` fields on `IAgentAttachment` are dropped by `AgentSideEffects` before reaching the agent (`agentSideEffects.ts:699-703, :934-938`), so a Selection-shape input is not realistically reachable in Phase 6. Test the realistic path: `sendMessage('hi', [{type: AttachmentType.File, uri: URI.parse('file:///a')}, {type: AttachmentType.Directory, uri: URI.parse('file:///b')}])`. After the call, inspect `FakeQuery.capturedPrompt` — the first `SDKUserMessage`'s `content` is `[{type:'text', text:'hi'}, {type:'text', text: matches /^[\s\S]*\/a[\s\S]*\/b/ }]`. Selection rendering is deferred to a future phase that expands `AgentSideEffects` to forward `text` + `selection`; the resolver's `Selection` branch is dead-code until then (per §3.7 note). + +### 5.2 Integration test (1 case) + +**File**: [../../test/node/claudeAgent.integration.test.ts](../../test/node/claudeAgent.integration.test.ts) + +Real `ClaudeProxyService` + real `@anthropic-ai/claude-agent-sdk` + stubbed `ICopilotApiService` returning a canned Anthropic stream `[message_start, content_block_start(text), content_block_delta('hello'), content_block_stop, message_delta(usage), message_stop]` followed by terminal SDK messages so `result` arrives. + +Asserts: +- The full proxy → SDK → mapper → AgentSignal pipeline emits the expected signal sequence. +- The SDK subprocess actually forks (assert `process.execPath` was used as executable). +- `Options.env` strip behavior: `NODE_OPTIONS` is undefined in the subprocess env, `ELECTRON_RUN_AS_NODE === '1'`. +- Cleanup: dispose the agent, no orphan subprocesses (assert `ps` doesn't show stale `claude-agent-sdk` children — or rely on the SDK's own `[Symbol.asyncDispose]` contract and assert no unhandled rejections). + +This test is the single real-world validator that the proxy's `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` plumbing actually works against the SDK. Roadmap explicit requirement at [roadmap.md L532](roadmap.md#L532). + +### 5.3 Removed from earlier draft + +- **(was) "SDK load failure → sendMessage rejects"**: Phase 5 already covers SDK lazy-load failure via `listSessions`. The new `startupRejection` field on `FakeClaudeAgentSdkService` covers init failure as a setup variant of test 3, not a separate test. + +### 5.4 Nice-to-have (not gating) + +- Concurrent `sendMessage` serialization via `_sessionSequencer`. +- `sendMessage` after shutdown → reject with `CancellationError`. +- `tool_use` leakage guard: if SDK ever delivers `content_block_start(tool_use)` despite `canUseTool: deny`, mapper skips + warns; the loop doesn't hang. + +## 6. Risks / gotchas + +| Risk | Mitigation | +|---|---| +| `startup()` doesn't honor `Options.abortController` during init handshake. | Belt-and-suspenders: after `await sdk.startup()` resolves, check `provisional.abortController.signal.aborted`; if true, `await warm[Symbol.asyncDispose]()` and throw `CancellationError`. Integration test exercises real abort during init. | +| `assistant` SDKMessages double-emit text already streamed via `stream_event`. | Mapper rule: with `includePartialMessages: true`, `assistant` whole-messages contribute ZERO `SessionDelta` / `SessionResponsePart` signals — only metadata (errors, defense-in-depth tool_use detection). Tests 6, 7, 9 codify the no-double-emit invariant. | +| Reducer corruption from out-of-order signals (`SessionDelta` before `SessionResponsePart`). | Mapper allocates the part on `content_block_start` BEFORE any deltas can arrive (deltas are SDK-ordered). Tests 6, 7 assert the precedence directly. | +| `tool_use` block leaks through `canUseTool: deny`. | Mapper skips + warns at `content_block_start(tool_use)`. Loop continues. SDK eventually surfaces the failed call via `result.error_during_execution`, which we log but don't re-raise. Test 5.4 nice-to-have. | +| `process.execPath` in a utility process needs `ELECTRON_RUN_AS_NODE=1`. | `_buildSubprocessEnv()` sets it. Mirror of `copilotAgent.ts:434-450`. Integration test asserts the env value. | +| `NODE_OPTIONS` from the parent Electron process breaks the Claude subprocess. | `_buildSubprocessEnv()` strips it via `undefined` (Options.env semantics, sdk.d.ts:1075-1078). Integration test asserts `NODE_OPTIONS === undefined` in the spawn env. | +| Provisional session resurrected by a duplicate `createSession` after the user disposed it. | `disposeSession(uri)` removes the provisional entry AND aborts its controller. A subsequent `createSession` for the same URI creates a new provisional record (new AbortController). The Phase-5 idempotency guard (`if (this._sessions.has(sessionId)) return ...`) only fires for already-materialized sessions; provisional re-creates after dispose are a fresh provisional. | +| `_sessionSequencer` and `_disposeSequencer` deadlock. | They are SEPARATE sequencers with the same key (sessionId). `disposeSession` enters `_disposeSequencer`; `sendMessage` enters `_sessionSequencer`. They can run in parallel for the same session. The race is benign: a concurrent dispose during materialize aborts the AbortController, which causes `await sdk.startup()` to reject inside `_sessionSequencer`. | +| Materialize-during-dispose race surfaces a half-born session in `_sessions`. | The `provisional.abortController.signal.aborted` check after `await sdk.startup()` (Q8 belt-and-suspenders) catches this and disposes the WarmQuery. Test 13b codifies. | +| `Query` AsyncIterable doesn't terminate on abort. | The session's `_processMessages` checks `signal.aborted` at the top of every iteration. The mapper's no-op fall-through plus the prompt iterable's abort-aware termination means we drop out of the `for await` cleanly. SDK comment at sdk.d.ts:982 promises Query cleanup on abort. | +| Workbench client retries `createSession` over a re-connection while the original `sendMessage` is still materializing. | Idempotency: the second `createSession` finds the session in `_provisionalSessions` and returns the same URI without creating a new record. The in-flight materialize on the first connection's send completes normally; the second connection awaits its own send. | +| `result.is_error: true` causes the turn to look stuck. | Mapper still emits `SessionTurnComplete` after logging the warning. `is_error` is informational on a successful turn (model decided to error in-band). Test in §5.4 nice-to-have. | +| Phase 9 cancellation looks like Phase 6 error. | Documented limitation: dispose-driven cancellation rejects in-flight deferred with `CancellationError`. AgentSideEffects doesn't yet discriminate, so it dispatches `SessionError` during shutdown. Harmless (state manager being torn down) but technically wrong. Phase 9 follow-up: discriminate `isCancellationError` in AgentSideEffects OR dispatch `SessionTurnCancelled` from the agent before reject. Cited in §8. | + +## 7. Acceptance criteria + +The PR is **done** when every box below is checked. + +### 7.1 Code structure + +- [ ] [claudeAgentSdkService.ts](claudeAgentSdkService.ts) exposes `startup()` on both `IClaudeSdkBindings` and `IClaudeAgentSdkService`. Phase-5 surface preserved. +- [ ] [claudeAgentSession.ts](claudeAgentSession.ts) is a Query owner with the fields enumerated in §3.5. Constructor takes `WarmQuery`, `AbortController`, and the agent's progress emitter. `dispose()` aborts the controller and disposes the WarmQuery. +- [ ] [claudeAgent.ts](claudeAgent.ts) has `_provisionalSessions: Map`, `_onDidMaterializeSession` Emitter, `_sessionSequencer: SequencerByKey` distinct from `_disposeSequencer`. `_createSessionWrapper` updated to take WarmQuery + AbortController. +- [ ] `createSession` non-fork returns `provisional: true`, fork branch throws `TODO: Phase 6.5`. +- [ ] `_materializeProvisional` builds the SDK `Options` per §3.4 (env strip, settings.env, includePartialMessages, canUseTool deny, abortController). +- [ ] [claudeMapSessionEvents.ts](claudeMapSessionEvents.ts) is a pure helper module exporting `mapSDKMessageToAgentSignals` and `IClaudeMapperState`. No I/O. No DI. +- [ ] [claudePromptResolver.ts](claudePromptResolver.ts) is a pure helper exporting `resolvePromptToContentBlocks`. No I/O. No DI. +- [ ] All Phase-7+ stubs (`respondToPermissionRequest`, `respondToUserInputRequest`, etc.) still throw `TODO: Phase N`. +- [ ] No `as any` / `as unknown as Foo` casts in production or test code. +- [ ] Microsoft copyright header on every new file. + +### 7.2 Persistence invariants (assert in tests) + +- [ ] Non-fork `createSession` does NOT call `ISessionDataService.openDatabase` or `tryOpenDatabase`, and does NOT call any `IClaudeAgentSdkService` method (no `startup()`, no `listSessions()`). +- [ ] `createSession({ fork })` rejects with a `TODO: Phase 6.5` error and produces no side effects. +- [ ] Materialize is the FIRST `startup()` call; `startupCallCount === 1` after first `sendMessage`, regardless of how many `createSession` retries happened beforehand. +- [ ] Dispose materialized session aborts the AbortController and rejects in-flight deferreds. +- [ ] Dispose provisional session does NOT call `startup()` and does NOT touch `_sessions`. + +### 7.3 Compile + lint + layers + +- [ ] `VS Code - Build` task shows zero TypeScript errors. If task is unavailable, `npm run compile-check-ts-native` exits 0. +- [ ] `npm run eslint -- src/vs/platform/agentHost/node/claude src/vs/platform/agentHost/test/node/claudeAgent.test.ts src/vs/platform/agentHost/test/node/claudeAgent.integration.test.ts` exits 0. +- [ ] `npm run valid-layers-check` exits 0. +- [ ] `npm run hygiene` exits 0. + +### 7.4 Tests + +- [ ] All Phase-5 cases still pass (no regression). +- [ ] All 15 unit cases from §5.1 pass. +- [ ] The integration test in §5.2 passes against the real SDK. +- [ ] `scripts/test.sh --grep ClaudeAgent` exits 0. +- [ ] `scripts/test-integration.sh --grep claudeAgent` exits 0 (or the equivalent integration runner per the workspace's test conventions). + +### 7.5 Live-system smoke (mandatory before merging) + +Phase-6 smoke checklist (6 boxes): + +- [ ] **Provisional defers `sessionAdded`.** Open new-chat, pick Claude, pick folder. The session appears in the workbench list ONLY after the first message lands. +- [ ] **Per-token streaming.** Type "hi" → assistant text appears incrementally (visible chunks during the response, not just at completion). +- [ ] **Persistence after first turn.** After the first turn completes, the session shows up in `listSessions()` (workbench reload — session is there). +- [ ] **Second turn reuses Query.** Second prompt streams without re-materializing, prior turn's content remains visible. +- [ ] **Mid-turn dispose.** Send a long-response prompt, dispose the session mid-stream. No unhandled rejection in the agent host log; session removed cleanly from the workbench. +- [ ] **Clean process teardown.** Kill the agent host process; `ps aux | grep claude` shows no orphan subprocesses; next startup has no error spam. + +(Fork smoke moves to Phase 6.5.) + +### 7.6 PR readiness + +- [ ] PR title: `agentHost/claude: Phase 6 — sendMessage (single-turn, no tools)`. +- [ ] PR description links to [roadmap.md](roadmap.md) Phase 6 and to this plan; notes that exit criteria are met. +- [ ] PR description lists the implemented changes vs the still-stubbed methods + their target phase. +- [ ] PR description calls out the deferred Phase 6.5 fork, the Phase 9 cancellation discrimination follow-up, and the canUseTool deny stub as Phase 7 surface. + +## 8. Phase 6.5 / Phase 7+ contract notes + +These are decisions Phase 6 locks down so later phases are pure-additive. + +### 8.1 Phase 6.5 — fork + +**Critical SDK divergence from CopilotAgent**: Claude SDK's `forkSession(sessionId, { upToMessageId, title })` at [sdk.d.ts:540-565](../../../../../../node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts) takes a **message UUID**, not an event id. This is structurally different from CopilotAgent's `getNextTurnEventId(turnId) → toEventId` pattern. Mirroring CopilotAgent's pattern would have been wrong. + +**Phase 6.5 implementation outline**: +- Add `forkSession` to `IClaudeSdkBindings` and `IClaudeAgentSdkService`. +- In `createSession({ fork })`, walk `sdk.getSessionMessages(srcSessionId)` to compute the `protocolTurnId → assistantMessageUuid` mapping lazily at fork time. SDK transcript is the source of truth — no Phase-6 metadata write needed. +- Resume the forked session so the SDK loads the forked history. +- Persist the customization-directory metadata via `setMetadata` on the forked session. +- Phase 6.5 is a stacked PR on top of Phase 6. + +### 8.2 Phase 7 — `canUseTool` + +Phase 6's stub returns `{ behavior: 'deny', message: 'Tools are not yet enabled for this session (Phase 6).' }`. Phase 7 flips this to call `IToolPermissionService.canUseTool(...)` (mirrors [`claudeCodeAgent.ts:467`](../../../../../../extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts#L467)). The mapper's defense-in-depth `tool_use` skip+warn becomes a pass-through to a new tool-call signal. + +### 8.3 Phase 9 — cancellation discrimination in AgentSideEffects + +Documented limitation: Phase 6's dispose-driven cancellation rejects in-flight deferreds with `CancellationError`. AgentSideEffects' `.catch()` at [`agentSideEffects.ts:704`](../agentSideEffects.ts#L704) doesn't yet discriminate cancellation from real failure, so it dispatches `SessionError` during shutdown. Phase 9's `abortSession` work needs to either (a) discriminate `isCancellationError` in AgentSideEffects, (b) dispatch `SessionTurnCancelled` from the agent before reject, or (c) both. + +### 8.4 Phase 7+ — `ClaudeMessageProcessor` extraction trigger + +Phase 6 keeps `_processMessages` as a private method on `ClaudeAgentSession`. The single-class decision is right at this surface area: the mapper helper already gives us pure-function testability, and the loop itself is thin orchestration. + +**Trigger to extract**: when `_processMessages` accretes any of: +- Tool-use dispatch (Phase 7) with `unprocessedToolCalls` map + per-tool span tracking. +- Hook-event handling (Phase 11) with `otelHookSpans` map. +- Edit-tracker integration (Phase 8). +- Subagent trace contexts (Phase 12). +- OTel `invoke_agent` span lifecycle. + +At that point — likely Phase 7 — extract a `ClaudeMessageProcessor` helper class registered (`_register`'d) by the session. Mirrors how the production extension's [`claudeCodeAgent.ts:578-700`](../../../../../../extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts#L578-L700) has clearly distinct concerns mashed together — we want to split them when they actually exist, not pre-emptively. + +### 8.5 Phase 7+ — sequencer reconvergence trigger + +Phase 6 deliberately uses **two separate** per-session sequencers: +- `_disposeSequencer` (Phase 5, teardown) at `claudeAgent.ts:153-165` +- `_sessionSequencer` (Phase 6, send + materialize) + +CopilotAgent uses a **single** sequencer ([`copilotAgent.ts:265`](../copilot/copilotAgent.ts#L265)) for sends, disposes, model changes, archive, etc. Phase 6's two-sequencer split is safe today because dispose and send are linked through the AbortController cascade: dispose → abort → SDK Query unwinds → `_processMessages` exits → in-flight deferred rejects. The sequencers don't deadlock because each holds a different per-key lock. + +**Trigger to converge**: when Phase 7 introduces tool-call confirmations that hold longer-lived in-flight state on the session (e.g. waiting on `respondToPermissionRequest`), the AbortController cascade is no longer the only synchronization point. At that phase, audit whether dispose-during-tool-confirmation needs a single sequencer to serialize. If yes, fold `_disposeSequencer` into `_sessionSequencer` and route both `disposeSession` and `sendMessage` through the same `queue(sessionId, ...)`. Mirrors CopilotAgent. + +### 8.6 Production extension `sdk.startup()` adoption (out of scope, recorded) + +The extension at [`claudeCodeAgent.ts:487`](../../../../../../extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts#L487) uses `query({ prompt, options })` directly. `sdk.startup()` is a strict upgrade for any "session is created before first prompt" flow but the extension doesn't have provisional/materialize semantics, so the gain is purely about subprocess pre-warming. Not on the agent-host roadmap. + +## 9. Resolved decisions (grilling outcomes) + +The full grilling transcript locked these. Recording the conclusions here so a fresh-context reader sees the rationale. + +**Q1: `canUseTool` stub.** Returns `{ behavior: 'deny', ... }`, not `allow`. `allow` would actually execute tools (filesystem mutations + multi-turn loops), exceeding Phase-6 scope. Mapper skip+warn for `tool_use` blocks is defense-in-depth on top. + +**Q2: Fork → Phase 6.5.** Claude SDK's `forkSession` API is structurally different from Copilot's (message UUID vs event id). Doing it right requires `sdk.getSessionMessages` lookup. Stacked PR keeps Phase 6 focused. + +**Q3: Skip metadata write on materialize, lazy backfill in Phase 6.5.** No `protocolTurnId → messageUUID` mapping written in Phase 6 because Phase 6 doesn't need it; Phase 6.5 computes lazily from `sdk.getSessionMessages(srcId)` on fork. + +**Q4: Materialization timing — `sdk.startup()`.** `startup({ options })` forks subprocess and completes init handshake before returning `WarmQuery`. Fire `onDidMaterializeSession` AFTER the await resolves → no phantom-session bug. + +**Q5: `_processMessages` on session class.** Not extracted to a separate class in Phase 6. Mapper helper provides the testability seam; the loop itself is thin. Extraction trigger documented (§8.4). + +**Q6: Signal emission via shared `Emitter`.** Session emits via the agent's emitter (passed in constructor) — not its own. Mirrors CopilotAgent's pattern. + +**Q7: `includePartialMessages: true`.** Per-token streaming UX. Production extension doesn't set this (chunky UX); we do. + +**Q8: Shutdown-during-materialize race.** Per-session `AbortController` lives on the provisional record. Pass into `Options.abortController`. On materialize success, ownership transfers to `ClaudeAgentSession` which registers `toDisposable(() => abort())`. Shutdown loops `_provisionalSessions` calling `abort()`; then drains `_sessions`. Native to SDK contract (sdk.d.ts:982). No agent-level controller, no parent/child wiring, no flags. + +**Q9: Prompt iterable termination via AbortController.** Same controller drives SDK cancellation, dispose chain, and iterator termination. Constructor wires `signal.addEventListener('abort', () => deferred.complete())` to wake parked iterator. No bespoke `_isDisposed` flag. + +**Q10: Attachment conversion → `` block.** Pure helper `claudePromptResolver.ts` mirrors production extension, simplified for the protocol's narrower attachment surface (no images, no inline ranges yet). + +**Q11: Env stripping — two SDK surfaces.** `Options.env` for subprocess process env (strip `NODE_OPTIONS`, `ANTHROPIC_API_KEY`, `VSCODE_*`, `ELECTRON_*`; set `ELECTRON_RUN_AS_NODE=1`). `Options.settings.env` for Claude session config (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC`, `USE_BUILTIN_RIPGREP`, `PATH`). + +**Q12: `_processMessages` error rules.** (1) Mapper throws → log + skip, no propagate. (2) SDK iterator throws OR ends without `result` → drain in-flight with reject + throw. (3) `result.is_error: true` → log warn, still complete the turn normally. Inlined drain (no helper methods — only two call sites). + +**Q13: `FakeClaudeAgentSdkService` shape.** Async-generator iterator, field-based capture, optional `queryAdvance` hook for timing-sensitive tests. `FakeWarmQuery` and `FakeQuery` helpers. + +**Q14: Refined test list.** 15 unit + 1 integration. Removed "SDK load failure" (Phase 5 covers it). Added mapper-throws test and attachment-conversion test (File/Directory only — selection-shape inputs descoped per S4 review finding). Split shutdown-drain into provisional-only and mixed scenarios. diff --git a/src/vs/platform/agentHost/node/claude/roadmap.md b/src/vs/platform/agentHost/node/claude/roadmap.md index 1b7f11699dd03..a9ac6ce279b69 100644 --- a/src/vs/platform/agentHost/node/claude/roadmap.md +++ b/src/vs/platform/agentHost/node/claude/roadmap.md @@ -339,7 +339,11 @@ Phase 4, (b) the deferred-concerns map for later phases, and (c) the one remaining open question (byte-equivalence) with a concrete plan to close it in Phase 4. No throw-away code committed. -### Phase 4 — `ClaudeAgent` skeleton implementing `IAgent` +### Phase 4 — `ClaudeAgent` skeleton implementing `IAgent` ✅ **DONE** + +Landed in [#313780](https://github.com/microsoft/vscode/pull/313780) +(commit `7211c0f3746`). Live-system smoke completed 2026-05-01 — see +[phase4-plan.md](./phase4-plan.md) §7.8. > **Implementation contract: [phase4-plan.md](./phase4-plan.md).** That file > is the source of truth for the Phase 4 PR — code skeleton, registration diff --git a/src/vs/platform/agentHost/node/claude/smoke.md b/src/vs/platform/agentHost/node/claude/smoke.md index bb9705d7af0f4..b59c258f77f87 100644 --- a/src/vs/platform/agentHost/node/claude/smoke.md +++ b/src/vs/platform/agentHost/node/claude/smoke.md @@ -3,22 +3,23 @@ A streamlined, repeatable smoke test for the `ClaudeAgent` IAgent provider. Use this whenever a phase changes the boot path, the registration code in `agentHostMain.ts` / `agentHostServerMain.ts`, the model filter in -`isClaudeModel`, or the GitHub-token plumbing through `IClaudeProxyService`. +`isClaudeModel`, the GitHub-token plumbing through `IClaudeProxyService`, +or (Phase 6+) the SDK subprocess fork / message pipeline. -It encodes everything we learned during the Phase 4 live walk so future runs -are deterministic. The two helper scripts under `./scripts/` capture the -boilerplate (launching the app, verifying the logs); the playwright steps -are still operator-driven because they depend on snapshot refs that change -between runs. +It encodes the lessons from the Phase 4 live walk and the Phase 6 cycles +so future runs are deterministic. The two helper scripts under `./scripts/` +capture the boilerplate (launching the app, verifying the logs); the +playwright steps are still operator-driven because they depend on snapshot +refs that change between runs. ## When to run | Phase | What this plan must continue to prove | |-------|--------------------------------------| | 4 (skeleton) | Both providers register; auth reaches `ClaudeAgent`; proxy binds; models surface; first user prompt throws `TODO: Phase 5` (the `createSession` stub fires before `sendMessage`). | -| 5 (sessions) | Same as above PLUS `createSession` succeeds; first user prompt throws `TODO: Phase 6`. | -| 6 (sendMessage) | Same as above PLUS prompt produces SDK output. | -| 7+ | Add per-phase assertion to the table in §6 below. | +| 5 (sessions) | Same as Phase 4 PLUS `createSession` succeeds (`claude:/` URI in IPC log); first user prompt throws `TODO: Phase 6`. **NOTE: Phase 5 was never run live — see §8.** | +| 6 (sendMessage, single-turn, no tools) | Same as Phase 4 PLUS `createSession` returns a *provisional* session (no SDK contact yet); first user prompt materializes the SDK subprocess and **renders a real text response** (no `TODO: Phase` match in the snapshot); IPC log carries `session/responsePart`, `session/delta`, `session/usage`, `session/turnComplete` actions. | +| 7+ | Add per-phase assertion to the table above. | ## Prerequisites @@ -50,10 +51,13 @@ port is listening. ## 2. Verify the agent host wiring (no UI required) ```bash -./src/vs/platform/agentHost/node/claude/scripts/verify-claude-logs.sh +./src/vs/platform/agentHost/node/claude/scripts/verify-claude-logs.sh [--phase=N] ``` -Exits non-zero if any of the five log-level invariants fail: +Default `--phase` is the latest implemented phase (currently 6). Pass +`--phase=4` or `--phase=5` to skip the Phase-6+ session-action checks. + +Exits non-zero if any invariant fails. Always-on checks (any phase ≥ 4): 1. Both `copilotcli` AND `claude` providers registered. 2. `[Claude] Auth token updated` appears (proves `agentService.authenticate` @@ -64,8 +68,21 @@ Exits non-zero if any of the five log-level invariants fail: 5. ≥ 1 Claude-family model id (`claude-opus-*`, `claude-sonnet-*`, …) surfaces in the IPC log — verifies the §3.5 model filter and `tryParseClaudeModelId`. +6. **No fatal error log lines** (any phase — these indicate bugs): + - `[Claude SDK stderr]` (Phase 6 subprocess error stream) + - `[ClaudeAgentSession] _processMessages crashed` (Phase 6 fatal loop) + - `[ClaudeAgentSession] mapper threw, skipping message` (Phase 6 mapper) + - `[Claude] Failed to persist customization directory; aborting materialize` (Phase 6 S5 fatal) + +Phase-6+ checks (only when `--phase ≥ 6` AND the operator has driven a turn +to completion via §4): + +7. ≥ 1 `"type":"session/responsePart"` action in the IPC log (proves the + mapper allocated a part — plan §3.6 reducer ordering invariant). +8. ≥ 1 `"type":"session/turnComplete"` action (proves the SDK reached + `result` and the consumer loop completed the in-flight deferred). -Captured artifacts land in `/tmp/claude-phase4-smoke//`: +Captured artifacts land in `/tmp/claude-smoke//`: - `registration.log` — both `Registering agent provider: …` lines - `auth.log` — `[Claude] Auth token …` @@ -73,6 +90,8 @@ Captured artifacts land in `/tmp/claude-phase4-smoke//`: - `root-state.log` — the claude block from a `RootStateChanged` event - `claude-models.log` — sample of model entries - `claude-session-uris.log` — every `claude:/` URI created +- `negatives.log` — grep results for the four fatal patterns (empty if pass) +- (Phase 6+) `response-actions.log` — sample `session/responsePart`/`turnComplete` envelopes ## 3. Verify the picker UI (operator-driven) @@ -111,7 +130,7 @@ lines. Capture screenshot for the PR: ```bash -SMOKE_DIR=$(ls -td /tmp/claude-phase4-smoke/*/ | head -1) +SMOKE_DIR=$(ls -td /tmp/claude-smoke/*/ | head -1) npx @playwright/cli screenshot --filename="$SMOKE_DIR/picker-open.png" ``` @@ -134,7 +153,15 @@ grep -nE 'Pick Session Type, Claude' "$SNAP" Expected: `button "Pick Session Type, Claude" [ref=…]`. -## 4. Drive a prompt to verify the stub fires +## 4. Drive a prompt + +What the prompt does is phase-dependent: + +- **Phase 4**: hits the `createSession` stub before `sendMessage`, so the snapshot shows `TODO: Phase 5`. +- **Phase 5**: `createSession` succeeds; `sendMessage` stub fires; snapshot shows `TODO: Phase 6`. +- **Phase 6+**: `createSession` returns a *provisional* session; the first `sendMessage` materializes the SDK subprocess and streams a real Claude response. Snapshot shows actual model output (e.g. “Hello! How can I help…”). No `TODO: Phase` match. + +With the picker still showing “Claude” selected, type and submit: ```bash # Find the chat textbox (its label is the placeholder text) @@ -144,31 +171,60 @@ grep -nE 'textbox.*\[active\]' "$SNAP" npx @playwright/cli click npx @playwright/cli type "hello claude" npx @playwright/cli press Enter -sleep 2 ``` -Re-snapshot and grep for the expected stub message: +**Phase 6 timing**: the SDK subprocess fork + init handshake takes a few +seconds on a cold start. Wait ≥5s before the first snapshot: + +```bash +sleep 5 +``` + +Re-snapshot and check the result against the phase you're on: ```bash npx @playwright/cli snapshot SNAP=$(ls -t .playwright-cli/page-*.yml | head -1) +# Phases 4-5 — stub fires; should match exactly one of these: grep -nE 'TODO: Phase' "$SNAP" +# Phase 6+ — should NOT match `TODO: Phase`. Instead grep for response: +grep -nE 'paragraph' "$SNAP" | head -5 ``` -Match the result against the phase-specific table: - | Phase | Expected snapshot match | |-------|------------------------| | 4 | `TODO: Phase 5` (createSession is the first stub on the path) | | 5 | `TODO: Phase 6` (sendMessage stub) | -| 6+ | no `TODO: Phase` match (real SDK response renders) | +| 6+ | no `TODO: Phase` match; one or more `paragraph` nodes with model output | Capture screenshot: ```bash +# Phase 4-5: stub error npx @playwright/cli screenshot --filename="$SMOKE_DIR/stub-error.png" +# Phase 6+: real response +npx @playwright/cli screenshot --filename="$SMOKE_DIR/turn-complete.png" ``` +**Phase 6+ — verify the action stream from logs.** After the turn completes, +re-run the verify script (it will look for `session/responsePart` and +`session/turnComplete` actions in the IPC log): + +```bash +./src/vs/platform/agentHost/node/claude/scripts/verify-claude-logs.sh --phase=6 +``` + +This catches issues that the snapshot can't — e.g. a turn that renders text +in the UI but never emitted `SessionUsage` (broken token accounting), or a +mapper that skipped `content_block_start` and only emitted deltas (broken +ordering invariant). + +> **Expected console error on Phase 6.** A single +> `[ERROR] TODO: Phase 10: Error: TODO: Phase 10` line in the playwright +> console capture is normal — the chat client invokes `setClientTools` to +> register its tool list, which is a Phase 10 stub. It does not affect the +> chat round-trip. Promote this to a check failure in Phase 10. + ## 5. Verify the session URI scheme The session URI is observable in the IPC log, **not** as a @@ -201,12 +257,35 @@ For a phase smoke PR, include in the description: - `registration.log` (two lines) - `picker-open.png` -- `stub-error.png` +- Phases 4–5: `stub-error.png` +- Phase 6+: `turn-complete.png` AND `response-actions.log` (proves the + IPC action stream landed, not just the UI render) - `claude-session-uris.log` (one line per session created) The other captured artifacts are useful for triage if any check fails but need not appear in every PR. +## 8. Phase 5 retroactive gap + +Phase 5 (the `IAgent` provider skeleton) was committed without a live +smoke run. The `--phase=5` row in §1 documents *what would have been* +verified — `createSession` succeeds, IPC log carries a `claude:/` +URI, prompt produces `TODO: Phase 6` — but the Phase 5 PR description +did not include any of the artifacts in §7. + +This is recorded here (rather than fixed retroactively) because Phase 6 +fully replaces the Phase 5 sendMessage path: a Phase 6 smoke run +transitively exercises every Phase 5 code path (provider registration, +auth fan-out, proxy bind, model surface, picker, session URI scheme), +and additionally proves the SDK subprocess fork + message pipeline. + +**Lesson for future phases**: every phase that touches the agent host +boot path or the IAgent surface MUST run this plan and attach the +§7 artifacts to its PR, even if the visible behavior is “stub message +changes from X to Y”. Without a live run, regressions in upstream layers +(authentication, proxy, model filter) only surface at the next phase +that does run live — by which point the bisect window is wider. + ## Appendix — common failures | Symptom | Likely cause | @@ -215,7 +294,13 @@ need not appear in every PR. | `verify-claude-logs.sh` fails at check 1 (`claude` missing) | Same, but for ClaudeAgent. Or import broken. | | `verify-claude-logs.sh` fails at check 2 (`[Claude] Auth token updated` missing) | `agentService.authenticate` is short-circuiting on the first matching provider. The fan-out fix lives in `src/vs/platform/agentHost/node/agentService.ts`. | | `verify-claude-logs.sh` fails at check 5 (zero models) | The §3.5 filter rejected everything. Inspect the upstream `[Copilot] Found N models` log line and check vendor / `supported_endpoints` / `model_picker_enabled` / `tool_calls`. | +| `verify-claude-logs.sh` fails at check 6 (`[Claude SDK stderr]`) | Phase 6 SDK subprocess wrote to stderr. Inspect the captured stderr in `agenthost.log` — likely auth (`401`/`403` from the proxy), missing `node` runtime, or the subprocess can't reach `ANTHROPIC_BASE_URL`. | +| `verify-claude-logs.sh` fails at check 6 (`_processMessages crashed`) | Phase 6 consumer loop hit an uncaught exception. The latched `_fatalError` is in the message; check whether it's a transport error or a bug in `claudeMapSessionEvents.ts`. | +| `verify-claude-logs.sh` fails at check 6 (`Failed to persist customization directory`) | Phase 6 S5 fatal — `_writeCustomizationDirectory` rejected. Check `ISessionDataService.openDatabase` permissions on the user-data-dir. | +| `verify-claude-logs.sh` fails at check 7 (no `session/responsePart`) | Phase 6 mapper returned no signals. The first `content_block_start` may be `tool_use` (Phase 7+) instead of `text`/`thinking`. Or `_inFlightRequests[0]?.turnId` was undefined when the first message arrived (sequencer race). | +| `verify-claude-logs.sh` fails at check 8 (no `session/turnComplete`) | Phase 6 SDK never reached `result`. The subprocess may still be running (cancellation didn't propagate), or the prompt iterable parked permanently. Check for `_processMessages crashed` first. | | Picker shows only "Copilot CLI" but registration log is fine | Root state never propagated. Check the `autorun` in `agentSideEffects.ts` — `_publishAgentInfos` should fire on every `agents` observable change. | -| Stub fires `TODO: Phase 5` but plan expected Phase 6 | Operator clicked Claude on a brand-new session, which hits `createSession` first. Either start from an existing claude session or update the per-phase table in §4. | +| Stub fires `TODO: Phase 5` but plan expected Phase 6 | Operator clicked Claude on a brand-new session, which hits `createSession` first. In Phase 5 this stub is normal; in Phase 6+ it indicates the materialize spine is missing — `createSession` should return `provisional: true` not throw. | +| Phase 6 prompt hangs without rendering text | Either (a) the SDK subprocess never started (check `[ClaudeProxyService]` access logs for the `/v1/messages` POST), (b) the proxy returned non-SSE bytes (check the proxy's stream-loop warn log), or (c) the mapper allocated no part-id and the UI has nothing to render. | | `npx @playwright/cli evaluate` returns a help screen | The command is `eval`, not `evaluate`. Use `--raw` to strip wrapper output. | | `npx @playwright/cli click` retries forever with `pointer-block intercepts` | Use keyboard navigation (`press ArrowDown` + `press Enter`). | diff --git a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts index f8f8b9cd723d0..3dcaef91a9eec 100644 --- a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts +++ b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts @@ -13,7 +13,7 @@ import { parseAgentHostDebugPort } from '../../environment/node/environmentServi import { ILogService } from '../../log/common/log.js'; import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; -import { AgentHostClaudeAgentEnabledSettingId, AgentHostEnableClaudeEnvVar } from '../common/agentService.js'; +import { AgentHostClaudeAgentSdkPathSettingId, AgentHostClaudeSdkPathEnvVar } from '../common/agentService.js'; /** * Options for configuring the agent host WebSocket server in the child process. @@ -74,11 +74,15 @@ export class NodeAgentHostStarter extends Disposable implements IAgentHostStarte }; // Gate optional providers via env vars consumed by `agentHostMain.ts`. - // The Claude agent is opt-in: enabled when either the workbench setting is on - // or the env var is already set on the parent process (developer override). - if (this._configurationService.getValue(AgentHostClaudeAgentEnabledSettingId) - || process.env[AgentHostEnableClaudeEnvVar] === '1') { - env[AgentHostEnableClaudeEnvVar] = '1'; + // The Claude agent is opt-in: enabled when the user points the SDK path + // setting at a locally-installed `@anthropic-ai/claude-agent-sdk` package, + // or when the env var is already set on the parent process (developer + // override). The SDK itself is intentionally not bundled with VS Code. + const claudeSdkPath = this._configurationService.getValue(AgentHostClaudeAgentSdkPathSettingId) + || process.env[AgentHostClaudeSdkPathEnvVar] + || ''; + if (claudeSdkPath) { + env[AgentHostClaudeSdkPathEnvVar] = claudeSdkPath; } // Forward WebSocket server configuration to the child process via env vars diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts new file mode 100644 index 0000000000000..dc8ea73831155 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts @@ -0,0 +1,595 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Integration test for Phase 6 ClaudeAgent. + * + * Wires together: + * - Real {@link ClaudeProxyService} bound to a real loopback HTTP listener. + * - Stubbed {@link ICopilotApiService} that yields a canned Anthropic + * `MessageStreamEvent` sequence. + * - Real {@link ClaudeAgent} driving the materialize lifecycle. + * - Recording {@link IClaudeAgentSdkService} that, on `startup()`, + * performs a real HTTP round-trip against the proxy using the + * `Options.settings.env.ANTHROPIC_BASE_URL` / + * `Options.settings.env.ANTHROPIC_AUTH_TOKEN` it received — exactly + * what the real Claude SDK subprocess would do when forked. + * + * The test does NOT fork the bundled `@anthropic-ai/claude-agent-sdk` + * subprocess. That fork is exercised live by the Phase 6 smoke run + * (`smoke.md`). What this test guarantees in CI is the cross-component + * wiring that connects the two: + * - The agent constructs `Bearer .` in a format the + * real proxy's auth parser accepts. + * - The agent passes the proxy's actual `baseUrl` through + * `Options.settings.env`. + * - The proxy's SSE encoding round-trips the canned upstream stream. + * - The agent's strip-env contract on `Options.env` + * (`NODE_OPTIONS===undefined`, `ELECTRON_RUN_AS_NODE==='1'`) is + * captured by what the SDK service receives. + * - Disposing the agent disposes the proxy handle and the WarmQuery + * (no orphan resources). + */ + +import type Anthropic from '@anthropic-ai/sdk'; +import type { Options, Query, SDKMessage, SDKResultSuccess, SDKSessionInfo, SDKSystemMessage, SDKUserMessage, WarmQuery } from '@anthropic-ai/claude-agent-sdk'; +import type { CCAModel } from '@vscode/copilot-api'; +import assert from 'assert'; +import type * as http from 'http'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; +import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; +import { ILogService, NullLogService } from '../../../log/common/log.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; +import { GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../common/agentService.js'; +import { IAgentHostGitService } from '../../node/agentHostGitService.js'; +import { ClaudeAgent } from '../../node/claude/claudeAgent.js'; +import { IClaudeAgentSdkService } from '../../node/claude/claudeAgentSdkService.js'; +import { ClaudeProxyService, IClaudeProxyService } from '../../node/claude/claudeProxyService.js'; +import { ICopilotApiService, type ICopilotApiServiceRequestOptions } from '../../node/shared/copilotApiService.js'; +import { createNoopGitService, createSessionDataService } from '../common/sessionTestHelpers.js'; + +// #region Test fixtures + +const ANTHROPIC_MODEL: CCAModel = { + id: 'claude-opus-4.6', + name: 'Claude Opus 4.6', + vendor: 'Anthropic', + supported_endpoints: ['/v1/messages'], + object: 'model', + version: '4.6', + is_chat_default: false, + is_chat_fallback: false, + model_picker_category: '', + model_picker_enabled: true, + preview: false, + billing: { is_premium: false, multiplier: 1, restricted_to: [] }, + capabilities: { + family: 'test', + limits: { max_context_window_tokens: 200_000, max_output_tokens: 8192, max_prompt_tokens: 200_000 }, + object: 'model_capabilities', + supports: { parallel_tool_calls: true, streaming: true, tool_calls: true, vision: false }, + tokenizer: 'o200k_base', + type: 'chat', + }, + policy: { state: 'enabled', terms: '' }, +}; + +const TEST_UUID = '11111111-2222-3333-4444-555555555555'; + +function makeMessage(model: string): Anthropic.Message { + return { + id: 'msg_int_test', + type: 'message', + role: 'assistant', + model, + content: [{ type: 'text', text: '', citations: null }], + stop_reason: 'end_turn', + stop_sequence: null, + stop_details: null, + container: null, + usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation: null, + cache_creation_input_tokens: null, + cache_read_input_tokens: null, + inference_geo: null, + server_tool_use: null, + service_tier: null, + }, + }; +} + +/** Canned Anthropic `MessageStreamEvent` sequence for the `messages` stub. */ +function makeCannedStream(model: string): Anthropic.MessageStreamEvent[] { + const message = makeMessage(model); + const contentBlockStart: Anthropic.RawContentBlockStartEvent = { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '', citations: [] }, + }; + const contentBlockDelta: Anthropic.RawContentBlockDeltaEvent = { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'hello' }, + }; + const messageDelta: Anthropic.RawMessageDeltaEvent = { + type: 'message_delta', + delta: { stop_reason: 'end_turn', stop_sequence: null, stop_details: null, container: null }, + usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: null, + cache_read_input_tokens: null, + server_tool_use: null, + }, + }; + return [ + { type: 'message_start', message }, + contentBlockStart, + contentBlockDelta, + { type: 'content_block_stop', index: 0 }, + messageDelta, + { type: 'message_stop' }, + ]; +} + +function makeSystemInitMessage(sessionId: string): SDKSystemMessage { + return { + type: 'system', + subtype: 'init', + apiKeySource: 'user', + claude_code_version: '0.0.0-test', + cwd: '/workspace', + tools: [], + mcp_servers: [], + model: 'claude-test', + permissionMode: 'default', + slash_commands: [], + output_style: 'default', + skills: [], + plugins: [], + uuid: TEST_UUID, + session_id: sessionId, + }; +} + +function makeResultSuccess(sessionId: string): SDKResultSuccess { + return { + type: 'result', + subtype: 'success', + duration_ms: 0, + duration_api_ms: 0, + is_error: false, + num_turns: 1, + result: '', + stop_reason: 'end_turn', + total_cost_usd: 0, + usage: { + cache_creation: { ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 0 }, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + inference_geo: 'unknown', + input_tokens: 0, + iterations: [], + output_tokens: 0, + server_tool_use: { web_fetch_requests: 0, web_search_requests: 0 }, + service_tier: 'standard', + speed: 'standard', + }, + modelUsage: {}, + permission_denials: [], + uuid: TEST_UUID, + session_id: sessionId, + }; +} + +// #endregion + +// #region Stubbed CAPI + +class StubCopilotApiService implements ICopilotApiService { + declare readonly _serviceBrand: undefined; + + streamEvents: Anthropic.MessageStreamEvent[] = []; + availableModels: CCAModel[] = [ANTHROPIC_MODEL]; + + readonly messagesCallCount = { count: 0 }; + + messages( + token: string, + request: Anthropic.MessageCreateParamsStreaming, + options?: ICopilotApiServiceRequestOptions, + ): AsyncGenerator; + messages( + token: string, + request: Anthropic.MessageCreateParamsNonStreaming, + options?: ICopilotApiServiceRequestOptions, + ): Promise; + messages( + token: string, + request: Anthropic.MessageCreateParams, + options?: ICopilotApiServiceRequestOptions, + ): AsyncGenerator | Promise { + this.messagesCallCount.count++; + if (request.stream) { + return this._stream(options); + } + return Promise.reject(new Error('non-streaming not used in integration test')); + } + + private async *_stream( + options: ICopilotApiServiceRequestOptions | undefined, + ): AsyncGenerator { + for (const ev of this.streamEvents) { + if (options?.signal?.aborted) { + const err = new Error('Aborted'); + (err as { name: string }).name = 'AbortError'; + throw err; + } + yield ev; + } + } + + async countTokens(): Promise { + throw new Error('countTokens not used in integration test'); + } + + async models(): Promise { + return this.availableModels; + } +} + +// #endregion + +// #region Recording SDK service that round-trips through the real proxy + +interface IProxyRoundTripResult { + readonly status: number; + readonly contentType: string | undefined; + readonly events: readonly { readonly type: string; readonly data: unknown }[]; +} + +/** + * Test double for {@link IClaudeAgentSdkService}. On `startup()`, performs + * a real HTTP `POST /v1/messages` against the proxy URL the agent passed + * via `Options.settings.env`, using the bearer the agent constructed. + * This stands in for the SDK subprocess's first model call so we can + * assert the agent → proxy → CAPI round-trip works without forking + * `@anthropic-ai/claude-agent-sdk`'s bundled CLI. + */ +class ProxyRoundTripSdkService implements IClaudeAgentSdkService { + declare readonly _serviceBrand: undefined; + + readonly capturedStartupOptions: Options[] = []; + readonly proxyRoundTrips: IProxyRoundTripResult[] = []; + + /** Messages the produced WarmQuery's Query will yield in order. */ + queryMessages: SDKMessage[] = []; + + readonly warmQueries: RoundTripWarmQuery[] = []; + + async listSessions(): Promise { + return []; + } + + async startup(params: { options: Options; initializeTimeoutMs?: number }): Promise { + this.capturedStartupOptions.push(params.options); + + const settings = params.options.settings; + const settingsEnv = (settings && typeof settings === 'object' && settings.env) ? settings.env : {}; + const baseUrl = settingsEnv['ANTHROPIC_BASE_URL']; + const bearer = settingsEnv['ANTHROPIC_AUTH_TOKEN']; + if (!baseUrl || !bearer) { + throw new Error('ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN missing from settings.env'); + } + + const result = await postSseToProxy(`${baseUrl}/v1/messages`, bearer, { + model: 'claude-opus-4-6', + messages: [{ role: 'user', content: 'hi' }], + stream: true, + max_tokens: 4096, + }); + this.proxyRoundTrips.push(result); + + const warm = new RoundTripWarmQuery(this); + this.warmQueries.push(warm); + return warm; + } +} + +class RoundTripWarmQuery implements WarmQuery { + asyncDisposeCount = 0; + closeCount = 0; + + constructor(private readonly _sdk: ProxyRoundTripSdkService) { } + + query(prompt: string | AsyncIterable): Query { + if (typeof prompt === 'string') { + throw new Error('integration test: agent host always passes an AsyncIterable'); + } + return new RoundTripQuery(prompt, this._sdk); + } + + close(): void { + this.closeCount++; + } + + async [Symbol.asyncDispose](): Promise { + this.asyncDisposeCount++; + } +} + +class RoundTripQuery implements AsyncGenerator { + private _index = 0; + private readonly _drainer: Promise; + + constructor(prompt: AsyncIterable, private readonly _sdk: ProxyRoundTripSdkService) { + // Drain the prompt iterable in the background so the agent's + // `_pendingPromptDeferred.complete()` actually pumps the queue. + const it = prompt[Symbol.asyncIterator](); + this._drainer = (async () => { + while (true) { + const r = await it.next(); + if (r.done) { + return; + } + } + })(); + } + + [Symbol.asyncIterator](): AsyncGenerator { + return this; + } + + async next(): Promise> { + if (this._index >= this._sdk.queryMessages.length) { + await this._drainer; + return { done: true, value: undefined }; + } + return { done: false, value: this._sdk.queryMessages[this._index++] }; + } + + async return(): Promise> { + return { done: true, value: undefined }; + } + + async throw(err: unknown): Promise> { + throw err; + } + + async interrupt(): Promise { /* not used */ } + + setPermissionMode(): never { throw new Error('not modeled'); } + setModel(): never { throw new Error('not modeled'); } + setMaxThinkingTokens(): never { throw new Error('not modeled'); } + applyFlagSettings(): never { throw new Error('not modeled'); } + initializationResult(): never { throw new Error('not modeled'); } + supportedCommands(): never { throw new Error('not modeled'); } + supportedModels(): never { throw new Error('not modeled'); } + supportedAgents(): never { throw new Error('not modeled'); } + mcpServerStatus(): never { throw new Error('not modeled'); } + getContextUsage(): never { throw new Error('not modeled'); } + reloadPlugins(): never { throw new Error('not modeled'); } + accountInfo(): never { throw new Error('not modeled'); } + rewindFiles(): never { throw new Error('not modeled'); } + readFile(): never { throw new Error('not modeled'); } + seedReadState(): never { throw new Error('not modeled'); } + reconnectMcpServer(): never { throw new Error('not modeled'); } + toggleMcpServer(): never { throw new Error('not modeled'); } + setMcpServers(): never { throw new Error('not modeled'); } + streamInput(): never { throw new Error('not modeled'); } + stopTask(): never { throw new Error('not modeled'); } + close(): void { /* no-op */ } + [Symbol.asyncDispose](): Promise { return Promise.resolve(); } +} + +// #endregion + +// #region HTTP helpers + +let _httpModule: typeof http | undefined; +async function getHttp(): Promise { + if (!_httpModule) { + _httpModule = await import('http'); + } + return _httpModule; +} + +async function postSseToProxy( + url: string, + bearer: string, + payload: object, +): Promise { + const httpMod = await getHttp(); + return new Promise((resolve, reject) => { + const u = new URL(url); + const body = JSON.stringify(payload); + const req = httpMod.request({ + hostname: u.hostname, + port: u.port, + path: u.pathname + u.search, + method: 'POST', + headers: { + 'Authorization': `Bearer ${bearer}`, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body).toString(), + 'Accept': 'text/event-stream', + 'anthropic-version': '2023-06-01', + }, + }, res => { + const chunks: Buffer[] = []; + res.on('data', c => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c))); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + resolve({ + status: res.statusCode ?? 0, + contentType: typeof res.headers['content-type'] === 'string' ? res.headers['content-type'] : undefined, + events: parseSseFrames(raw), + }); + }); + res.on('error', reject); + }); + req.on('error', reject); + req.write(body); + req.end(); + }); +} + +function parseSseFrames(raw: string): { type: string; data: unknown }[] { + const out: { type: string; data: unknown }[] = []; + for (const block of raw.split('\n\n')) { + if (!block.trim()) { + continue; + } + let event = ''; + let data = ''; + for (const line of block.split('\n')) { + if (line.startsWith('event: ')) { + event = line.slice('event: '.length).trim(); + } else if (line.startsWith('data: ')) { + data = line.slice('data: '.length); + } + } + if (event && data) { + let parsed: unknown; + try { parsed = JSON.parse(data); } catch { parsed = data; } + out.push({ type: event, data: parsed }); + } + } + return out; +} + +// #endregion + +// #region Suite + +suite('ClaudeAgent integration (proxy-backed)', function () { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('agent → proxy → CAPI → SSE → agent: end-to-end pipeline with real proxy and stubbed CAPI', async () => { + // This is the Phase 6 §5.2 integration test: real ClaudeProxyService + // + real ClaudeAgent + stubbed ICopilotApiService + recording SDK + // service that performs a real HTTP round-trip on the proxy from + // inside `startup()`. Catches regressions in any of: + // - Agent's `Options.settings.env` wiring (BASE_URL / AUTH_TOKEN). + // - Proxy's `Bearer .` parser. + // - Proxy's model-id rewrite (SDK ↔ endpoint format). + // - Proxy's SSE frame encoding. + // - Agent's `Options.env` strip contract. + const capi = new StubCopilotApiService(); + capi.streamEvents = makeCannedStream('claude-opus-4.6'); + + const realProxy = disposables.add(new ClaudeProxyService(new NullLogService(), capi)); + const sdk = new ProxyRoundTripSdkService(); + + const services = new ServiceCollection( + [ILogService, new NullLogService()], + [ICopilotApiService, capi], + [IClaudeProxyService, realProxy], + [ISessionDataService, createSessionDataService()], + [IClaudeAgentSdkService, sdk], + [IAgentHostGitService, createNoopGitService()], + ); + const instantiationService = disposables.add(new InstantiationService(services)); + const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); + + // Authenticate — boots the proxy and snapshots the model list. + const accepted = await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'gh-int-test-token'); + assert.strictEqual(accepted, true); + + // Create a provisional session — no SDK contact yet. + const created = await agent.createSession({ workingDirectory: URI.file('/integration-cwd') }); + assert.strictEqual(sdk.capturedStartupOptions.length, 0, 'createSession does not touch the SDK'); + + // Stage a transcript on the SDK so `sendMessage` resolves. + const sessionId = created.session.path.replace(/^\//, ''); + sdk.queryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + + // First send materializes — drives `startup()`, which performs + // the real HTTP round-trip on the real proxy. + await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + + // Snapshot what flowed through the integration in a single + // assertion so the failure surface is the whole pipeline. + const startup = sdk.capturedStartupOptions[0]; + const round = sdk.proxyRoundTrips[0]; + const startupSettings = startup.settings; + const settingsEnv = (startupSettings && typeof startupSettings === 'object' && startupSettings.env) ? startupSettings.env : {}; + assert.deepStrictEqual({ + startupCallCount: sdk.capturedStartupOptions.length, + roundTripCount: sdk.proxyRoundTrips.length, + capiCallCount: capi.messagesCallCount.count, + startupCwd: startup.cwd, + startupSessionId: startup.sessionId, + startupExecutable: startup.executable, + subprocessElectronRunAsNode: startup.env?.['ELECTRON_RUN_AS_NODE'], + subprocessNodeOptions: startup.env?.['NODE_OPTIONS'], + subprocessAnthropicApiKey: startup.env?.['ANTHROPIC_API_KEY'], + settingsBaseUrlIsLoopback: typeof settingsEnv['ANTHROPIC_BASE_URL'] === 'string' + && settingsEnv['ANTHROPIC_BASE_URL'].startsWith('http://127.0.0.1:'), + settingsBearerHasNonceAndSession: typeof settingsEnv['ANTHROPIC_AUTH_TOKEN'] === 'string' + && settingsEnv['ANTHROPIC_AUTH_TOKEN'].split('.').length === 2 + && settingsEnv['ANTHROPIC_AUTH_TOKEN'].endsWith(`.${sessionId}`), + httpStatus: round.status, + httpContentType: round.contentType, + eventTypes: round.events.map(e => e.type), + }, { + startupCallCount: 1, + roundTripCount: 1, + capiCallCount: 1, + startupCwd: URI.file('/integration-cwd').fsPath, + startupSessionId: sessionId, + startupExecutable: process.execPath, + subprocessElectronRunAsNode: '1', + subprocessNodeOptions: undefined, + subprocessAnthropicApiKey: undefined, + settingsBaseUrlIsLoopback: true, + settingsBearerHasNonceAndSession: true, + httpStatus: 200, + httpContentType: 'text/event-stream', + eventTypes: [ + 'message_start', + 'content_block_start', + 'content_block_delta', + 'content_block_stop', + 'message_delta', + 'message_stop', + ], + }); + + // Cleanup: dispose the agent and assert the WarmQuery was + // closed via Symbol.asyncDispose (no orphan subprocess). + await agent.disposeSession(created.session); + assert.strictEqual(sdk.warmQueries[0].asyncDisposeCount, 1, 'WarmQuery is asyncDisposed on session dispose'); + }); + + test('proxy rejects a request whose bearer carries a wrong nonce (auth contract)', async () => { + // Companion test that locks the proxy's auth contract from + // outside the agent. If the agent ever drifts away from + // `Bearer .`, the round-trip in the test + // above fails — but this test guarantees the proxy itself + // rejects forged bearers regardless of the agent. + const capi = new StubCopilotApiService(); + const realProxy = disposables.add(new ClaudeProxyService(new NullLogService(), capi)); + const handle = await realProxy.start('gh-int-test-token'); + try { + const result = await postSseToProxy( + `${handle.baseUrl}/v1/messages`, + 'wrong-nonce.session-x', + { model: 'claude-opus-4-6', messages: [], stream: true }, + ); + assert.strictEqual(result.status, 401); + assert.strictEqual(capi.messagesCallCount.count, 0, 'auth check fires before any upstream call'); + } finally { + handle.dispose(); + } + }); +}); + +// #endregion diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 18d184fd4228b..22f56ee7947fd 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -4,11 +4,27 @@ *--------------------------------------------------------------------------------------------*/ import type Anthropic from '@anthropic-ai/sdk'; +import type { Options, Query, SDKMessage, SDKPartialAssistantMessage, SDKResultSuccess, SDKSessionInfo, SDKSystemMessage, SDKUserMessage, WarmQuery } from '@anthropic-ai/claude-agent-sdk'; import type { CCAModel } from '@vscode/copilot-api'; + +// Beta event-stream type aliases. The Anthropic namespace re-exports these +// from `@anthropic-ai/sdk/resources/beta/messages.js`, but importing that +// subpath directly trips the `local/code-import-patterns` allowlist +// (the agentHost rule only permits the bare `@anthropic-ai/sdk` specifier). +// Local aliases via the existing `Anthropic` import keep the body of this +// file readable without extending the allowlist. +type BetaRawContentBlockDeltaEvent = Anthropic.Beta.BetaRawContentBlockDeltaEvent; +type BetaRawContentBlockStartEvent = Anthropic.Beta.BetaRawContentBlockStartEvent; +type BetaRawContentBlockStopEvent = Anthropic.Beta.BetaRawContentBlockStopEvent; +type BetaRawMessageStartEvent = Anthropic.Beta.BetaRawMessageStartEvent; +type BetaRawMessageStopEvent = Anthropic.Beta.BetaRawMessageStopEvent; import assert from 'assert'; import { DeferredPromise } from '../../../../base/common/async.js'; +import { Event } from '../../../../base/common/event.js'; import type { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; +import { isUUID } from '../../../../base/common/uuid.js'; +import { isCancellationError } from '../../../../base/common/errors.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; @@ -16,12 +32,17 @@ import { IInstantiationService } from '../../../instantiation/common/instantiati import { ILogService, NullLogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { FileService } from '../../../files/common/fileService.js'; -import { AgentSession } from '../../common/agentService.js'; +import { IAgentMaterializeSessionEvent, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../common/agentService.js'; +import { ActionType } from '../../common/state/sessionActions.js'; +import { ResponsePartKind, AttachmentType } from '../../common/state/sessionState.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; +import { IAgentHostGitService } from '../../node/agentHostGitService.js'; import { ClaudeAgent } from '../../node/claude/claudeAgent.js'; +import { ClaudeAgentSdkService, IClaudeAgentSdkService, IClaudeSdkBindings } from '../../node/claude/claudeAgentSdkService.js'; import { IClaudeProxyHandle, IClaudeProxyService } from '../../node/claude/claudeProxyService.js'; import { ICopilotApiService, type ICopilotApiServiceRequestOptions } from '../../node/shared/copilotApiService.js'; import { AgentService } from '../../node/agentService.js'; -import { createNoopGitService, createNullSessionDataService } from '../common/sessionTestHelpers.js'; +import { createNoopGitService, createNullSessionDataService, createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; // #region Test fakes @@ -57,6 +78,404 @@ class FakeCopilotApiService implements ICopilotApiService { countTokens(): Promise { throw new Error('not used in ClaudeAgent tests'); } } +class FakeClaudeAgentSdkService implements IClaudeAgentSdkService { + declare readonly _serviceBrand: undefined; + + /** + * Mutable list returned by {@link listSessions}. Tests assign it + * before invoking the agent under test. Defaults to empty so suites + * that don't care about session enumeration aren't forced to set it. + */ + sessionList: readonly SDKSessionInfo[] = []; + listSessionsCallCount = 0; + + /** + * Phase 6: counts {@link startup} invocations. The Phase-6 contract + * is that materialization is the FIRST `startup()` call, so this + * field anchors invariants like "non-fork createSession does not + * touch the SDK" and "materialize fires exactly once". + */ + startupCallCount = 0; + + /** + * Captures every {@link Options} argument forwarded to {@link startup}. + * Tests assert env strip, abortController identity, sessionId / resume + * routing, and the canUseTool stub via this list. + */ + readonly capturedStartupOptions: Options[] = []; + + /** + * Programmable rejection for {@link startup}. Set per test to simulate + * SDK init failure (corrupt postinstall, network error, abort during + * init handshake). Cleared automatically after the first throw — set + * to a fresh value if a test wants repeated failures. + */ + startupRejection: Error | undefined; + + /** + * Messages the {@link FakeQuery} produced by `warm.query(...)` will + * yield. Tests stage the SDK transcript here before invoking + * `sendMessage`. The default empty array means the prompt iterable + * is consumed but no messages stream back — useful for tests that + * never expect a `result` (e.g. cancellation paths). + */ + nextQueryMessages: SDKMessage[] = []; + + /** + * Optional async hook invoked between yielded messages. Tests use it + * to block the iterator at a specific index so concurrent + * `sendMessage` / `disposeSession` / `shutdown` races can be staged + * deterministically. Resolves immediately when undefined. + */ + queryAdvance: ((index: number) => Promise) | undefined; + + /** All warm queries produced by {@link startup}. Last entry is the most recent. */ + readonly warmQueries: FakeWarmQuery[] = []; + + /** + * Programmable rejection for {@link listSessions}. Set per test to + * simulate the SDK dynamic import failing (corrupt postinstall, + * missing optional dep). Mirror of {@link startupRejection}. + */ + listSessionsRejection: Error | undefined; + + async listSessions(): Promise { + this.listSessionsCallCount++; + if (this.listSessionsRejection) { + const err = this.listSessionsRejection; + throw err; + } + return this.sessionList; + } + + async startup(params: { options: Options; initializeTimeoutMs?: number }): Promise { + this.startupCallCount++; + this.capturedStartupOptions.push(params.options); + if (this.startupRejection) { + const err = this.startupRejection; + this.startupRejection = undefined; + throw err; + } + const warm = new FakeWarmQuery(this); + this.warmQueries.push(warm); + return warm; + } +} + +/** + * Test double for `WarmQuery`. Each instance is bound to a single + * `FakeClaudeAgentSdkService` so mutations to `nextQueryMessages` after + * `startup()` resolves but before `warm.query(...)` runs still propagate. + */ +class FakeWarmQuery implements WarmQuery { + queryCallCount = 0; + asyncDisposeCount = 0; + closeCount = 0; + /** The {@link FakeQuery} returned from `query()`. Undefined before. */ + produced: FakeQuery | undefined; + + constructor(private readonly _sdk: FakeClaudeAgentSdkService) { } + + query(prompt: string | AsyncIterable): Query { + this.queryCallCount++; + if (typeof prompt === 'string') { + throw new Error('FakeWarmQuery: agent host always passes an AsyncIterable, never a string prompt'); + } + const q = new FakeQuery(prompt, this._sdk); + this.produced = q; + return q; + } + + close(): void { + this.closeCount++; + } + + async [Symbol.asyncDispose](): Promise { + this.asyncDisposeCount++; + } +} + +/** + * Test double for the SDK's `Query` AsyncGenerator. Snapshots the bound + * prompt iterable on construction so tests can assert on what the agent + * actually pushed to the SDK, then yields messages from + * {@link FakeClaudeAgentSdkService.nextQueryMessages} in order. + */ +class FakeQuery implements AsyncGenerator { + /** The iterable passed to `warm.query(...)`. */ + readonly capturedPrompt: AsyncIterable; + + /** Prompts the agent has actually pushed (drained from `capturedPrompt` by `_collectPrompts`). */ + readonly drainedPrompts: SDKUserMessage[] = []; + + interruptCount = 0; + returnCount = 0; + throwCount = 0; + + private _yieldIndex = 0; + + constructor(prompt: AsyncIterable, private readonly _sdk: FakeClaudeAgentSdkService) { + this.capturedPrompt = prompt; + const iterator = prompt[Symbol.asyncIterator](); + // Drain the prompt iterable in the background so the agent's + // `_pendingPromptDeferred.complete()` actually pumps the queue. + // The real SDK consumes prompts as they arrive; this fake mirrors + // that pull behavior without waiting for the full transcript first. + void (async () => { + while (true) { + const r = await iterator.next(); + if (r.done) { + return; + } + this.drainedPrompts.push(r.value); + } + })(); + } + + [Symbol.asyncIterator](): AsyncGenerator { + return this; + } + + async next(): Promise> { + if (this._sdk.queryAdvance) { + await this._sdk.queryAdvance(this._yieldIndex); + } + if (this._yieldIndex >= this._sdk.nextQueryMessages.length) { + return { done: true, value: undefined }; + } + const value = this._sdk.nextQueryMessages[this._yieldIndex++]; + return { done: false, value }; + } + + async return(_value: void): Promise> { + this.returnCount++; + return { done: true, value: undefined }; + } + + async throw(err: unknown): Promise> { + this.throwCount++; + throw err; + } + + async interrupt(): Promise { + this.interruptCount++; + } + + // Phase 6 doesn't exercise the rest of the Query control surface; if a + // test trips one of these, surface it loudly so we know to model it. + setPermissionMode(): never { throw new Error('FakeQuery: setPermissionMode not modeled'); } + setModel(): never { throw new Error('FakeQuery: setModel not modeled'); } + setMaxThinkingTokens(): never { throw new Error('FakeQuery: setMaxThinkingTokens not modeled'); } + applyFlagSettings(): never { throw new Error('FakeQuery: applyFlagSettings not modeled'); } + initializationResult(): never { throw new Error('FakeQuery: initializationResult not modeled'); } + supportedCommands(): never { throw new Error('FakeQuery: supportedCommands not modeled'); } + supportedModels(): never { throw new Error('FakeQuery: supportedModels not modeled'); } + supportedAgents(): never { throw new Error('FakeQuery: supportedAgents not modeled'); } + mcpServerStatus(): never { throw new Error('FakeQuery: mcpServerStatus not modeled'); } + getContextUsage(): never { throw new Error('FakeQuery: getContextUsage not modeled'); } + reloadPlugins(): never { throw new Error('FakeQuery: reloadPlugins not modeled'); } + accountInfo(): never { throw new Error('FakeQuery: accountInfo not modeled'); } + rewindFiles(): never { throw new Error('FakeQuery: rewindFiles not modeled'); } + readFile(): never { throw new Error('FakeQuery: readFile not modeled'); } + seedReadState(): never { throw new Error('FakeQuery: seedReadState not modeled'); } + reconnectMcpServer(): never { throw new Error('FakeQuery: reconnectMcpServer not modeled'); } + toggleMcpServer(): never { throw new Error('FakeQuery: toggleMcpServer not modeled'); } + setMcpServers(): never { throw new Error('FakeQuery: setMcpServers not modeled'); } + streamInput(): never { throw new Error('FakeQuery: streamInput not modeled'); } + stopTask(): never { throw new Error('FakeQuery: stopTask not modeled'); } + close(): void { /* no-op */ } + [Symbol.asyncDispose](): Promise { return Promise.resolve(); } +} + +// #region SDK message builders +// +// The SDK's `SDKMessage` union has many required fields that aren't +// relevant to most agent-host tests (deep `NonNullableUsage` shape, +// `SDKSystemMessage`'s `tools`/`mcp_servers`/etc.). These builders +// produce fully-typed values without `as unknown` casts so tests can +// stage transcripts ergonomically. + +/** Stable test UUID — reused so assertions can pin against a known value. */ +const TEST_UUID = '11111111-2222-3333-4444-555555555555'; + +function makeNonNullableUsage(): SDKResultSuccess['usage'] { + return { + cache_creation: { ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 0 }, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + inference_geo: 'unknown', + input_tokens: 0, + iterations: [], + output_tokens: 0, + server_tool_use: { web_fetch_requests: 0, web_search_requests: 0 }, + service_tier: 'standard', + speed: 'standard', + }; +} + +function makeSystemInitMessage(sessionId: string): SDKSystemMessage { + return { + type: 'system', + subtype: 'init', + apiKeySource: 'user', + claude_code_version: '0.0.0-test', + cwd: '/workspace', + tools: [], + mcp_servers: [], + model: 'claude-test', + permissionMode: 'default', + slash_commands: [], + output_style: 'default', + skills: [], + plugins: [], + uuid: TEST_UUID, + session_id: sessionId, + }; +} + +function makeResultSuccess(sessionId: string): SDKResultSuccess { + return { + type: 'result', + subtype: 'success', + duration_ms: 0, + duration_api_ms: 0, + is_error: false, + num_turns: 1, + result: '', + stop_reason: 'end_turn', + total_cost_usd: 0, + usage: makeNonNullableUsage(), + modelUsage: {}, + permission_denials: [], + uuid: TEST_UUID, + session_id: sessionId, + }; +} + +// `stream_event` (SDKPartialAssistantMessage) builders. The SDK's +// `Options.includePartialMessages: true` setting (Phase 6 §3.4) routes +// raw `BetaRawMessageStreamEvent`s through to the agent so we can map +// per-token. The deep `BetaMessage` shape on `message_start` carries +// many required fields irrelevant to mapping; these helpers populate +// only what the mapper reads, with everything else set to safe zero +// values so the SDK type-checks pass without `as unknown` casts. + +function makeStreamEvent( + sessionId: string, + event: SDKPartialAssistantMessage['event'], +): SDKPartialAssistantMessage { + return { + type: 'stream_event', + event, + parent_tool_use_id: null, + uuid: TEST_UUID, + session_id: sessionId, + }; +} + +function makeMessageStart(): BetaRawMessageStartEvent { + return { + type: 'message_start', + message: { + id: 'msg_test', + type: 'message', + role: 'assistant', + model: 'claude-test', + content: [], + stop_reason: null, + stop_sequence: null, + stop_details: null, + container: null, + context_management: null, + usage: { + cache_creation: { ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 0 }, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + inference_geo: 'unknown', + input_tokens: 0, + iterations: [], + output_tokens: 0, + server_tool_use: { web_fetch_requests: 0, web_search_requests: 0 }, + service_tier: 'standard', + speed: 'standard', + }, + }, + }; +} + +function makeContentBlockStartText(index: number): BetaRawContentBlockStartEvent { + return { + type: 'content_block_start', + index, + content_block: { type: 'text', text: '', citations: null }, + }; +} + +function makeContentBlockStartThinking(index: number): BetaRawContentBlockStartEvent { + return { + type: 'content_block_start', + index, + content_block: { type: 'thinking', thinking: '', signature: '' }, + }; +} + +function makeTextDelta(index: number, text: string): BetaRawContentBlockDeltaEvent { + return { + type: 'content_block_delta', + index, + delta: { type: 'text_delta', text }, + }; +} + +function makeThinkingDelta(index: number, thinking: string): BetaRawContentBlockDeltaEvent { + return { + type: 'content_block_delta', + index, + delta: { type: 'thinking_delta', thinking }, + }; +} + +function makeContentBlockStop(index: number): BetaRawContentBlockStopEvent { + return { + type: 'content_block_stop', + index, + }; +} + +function makeMessageStop(): BetaRawMessageStopEvent { + return { type: 'message_stop' }; +} + +// #endregion + +/** + * Wraps a delegate {@link ISessionDataService} and records call counts so + * tests can assert that lifecycle methods (e.g. non-fork `createSession`) + * don't touch the database. The delegate's behavior is preserved verbatim. + */ +class RecordingSessionDataService implements ISessionDataService { + declare readonly _serviceBrand: undefined; + + openDatabaseCallCount = 0; + tryOpenDatabaseCallCount = 0; + + constructor(private readonly _delegate: ISessionDataService) { } + + getSessionDataDir(session: URI) { return this._delegate.getSessionDataDir(session); } + getSessionDataDirById(sessionId: string) { return this._delegate.getSessionDataDirById(sessionId); } + openDatabase(session: URI) { + this.openDatabaseCallCount++; + return this._delegate.openDatabase(session); + } + tryOpenDatabase(session: URI) { + this.tryOpenDatabaseCallCount++; + return this._delegate.tryOpenDatabase(session); + } + deleteSessionData(session: URI) { return this._delegate.deleteSessionData(session); } + cleanupOrphanedData(knownSessionIds: Set) { return this._delegate.cleanupOrphanedData(knownSessionIds); } + whenIdle() { return this._delegate.whenIdle(); } +} + // #endregion // #region Fixture models @@ -64,7 +483,7 @@ class FakeCopilotApiService implements ICopilotApiService { /** Build a {@link CCAModel} with sensible defaults; override per test. */ function makeModel(overrides: Partial & { readonly id: string; readonly name: string; readonly vendor: string }): CCAModel { return { - billing: { is_premium: false, multiplier: 1, restricted_to: [] } as unknown as CCAModel['billing'], + billing: { is_premium: false, multiplier: 1, restricted_to: [] }, capabilities: { family: 'test', limits: { max_context_window_tokens: 200_000, max_output_tokens: 8192, max_prompt_tokens: 200_000 }, @@ -118,21 +537,28 @@ interface ITestContext { readonly agent: ClaudeAgent; readonly proxy: FakeClaudeProxyService; readonly api: FakeCopilotApiService; + readonly sdk: FakeClaudeAgentSdkService; + readonly sessionData: RecordingSessionDataService; } function createTestContext(disposables: Pick): ITestContext { const proxy = new FakeClaudeProxyService(); const api = new FakeCopilotApiService(); api.models = async () => [...ALL_MODELS]; + const sdk = new FakeClaudeAgentSdkService(); + const sessionData = new RecordingSessionDataService(createSessionDataService()); const services = new ServiceCollection( [ILogService, new NullLogService()], [ICopilotApiService, api], [IClaudeProxyService, proxy], + [ISessionDataService, sessionData], + [IClaudeAgentSdkService, sdk], + [IAgentHostGitService, createNoopGitService()], ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); - return { agent, proxy, api }; + return { agent, proxy, api, sdk, sessionData }; } /** Drains the microtask queue so awaited refresh writes settle. */ @@ -271,6 +697,9 @@ suite('ClaudeAgent', () => { [ILogService, new NullLogService()], [ICopilotApiService, api], [IClaudeProxyService, proxy], + [ISessionDataService, createNullSessionDataService()], + [IClaudeAgentSdkService, new FakeClaudeAgentSdkService()], + [IAgentHostGitService, createNoopGitService()], ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -329,6 +758,8 @@ suite('ClaudeAgent', () => { [ILogService, new NullLogService()], [ICopilotApiService, api], [IClaudeProxyService, proxy], + [ISessionDataService, createNullSessionDataService()], + [IClaudeAgentSdkService, new FakeClaudeAgentSdkService()], ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); const agent = instantiationService.createInstance(ClaudeAgent); @@ -342,35 +773,64 @@ suite('ClaudeAgent', () => { assert.strictEqual(proxy.disposeCount, 1); }); - test('stubbed methods throw with the right phase number', () => { + test('stubbed methods throw with the right phase number', async () => { + // `abortSession` and `changeModel` MUST return a rejected promise + // (not throw synchronously). AgentSideEffects.handleAction chains + // `.catch()` on the result to surface the error as a SessionError + // action; a synchronous throw escapes that chain and the workbench + // hangs forever on a turn that never finishes (the live smoke + // caught this in the Phase 5 walk). + // `respondToPermissionRequest`/`respondToUserInputRequest` are + // `void`-returning by interface, so they throw synchronously and we + // capture that via try/catch. + // + // Phase 6 update: `sendMessage` graduated from the stubbed list — + // it now materializes the provisional session and forwards to + // `ClaudeAgentSession.send`. Its negative path (unknown session + // id) is covered by Cycle 12; keep this test focused on stubs. const { agent } = createTestContext(disposables); - const cases: Array<{ name: string; phase: number; thunk: () => unknown }> = [ - { name: 'createSession', phase: 5, thunk: () => agent.createSession() }, - { name: 'sendMessage', phase: 6, thunk: () => agent.sendMessage(URI.parse('claude:/x'), 'hi') }, - { name: 'respondToPermissionRequest', phase: 7, thunk: () => agent.respondToPermissionRequest('id', true) }, + const promiseCases: Array<{ name: string; phase: number; thunk: () => Promise }> = [ { name: 'abortSession', phase: 9, thunk: () => agent.abortSession(URI.parse('claude:/x')) }, + { name: 'changeModel', phase: 9, thunk: () => agent.changeModel(URI.parse('claude:/x'), { id: 'claude-opus-4.5' }) }, ]; - const observed = cases.map(c => { + const voidCases: Array<{ name: string; phase: number; thunk: () => void }> = [ + { name: 'respondToPermissionRequest', phase: 7, thunk: () => agent.respondToPermissionRequest('id', true) }, + ]; + + const observed: Array<{ name: string; message: string; sync: boolean }> = []; + for (const c of promiseCases) { + let p: Promise; try { - const result = c.thunk(); - if (result instanceof Promise) { - // Surface the rejection synchronously for snapshotting. - let err: Error | undefined; - result.catch(e => { err = e instanceof Error ? e : new Error(String(e)); }); - // Async stubs throw synchronously in this implementation, - // but if a future stub uses `async` the thunk will return - // a rejected promise — fall through and miss the assertion. - return { name: c.name, message: err?.message ?? 'no-throw' }; - } - return { name: c.name, message: 'no-throw' }; + p = c.thunk(); } catch (e) { - return { name: c.name, message: e instanceof Error ? e.message : String(e) }; + // Synchronous throw — the bug we're guarding against. + observed.push({ name: c.name, message: e instanceof Error ? e.message : String(e), sync: true }); + continue; } - }); + let message = 'no-throw'; + try { + await p; + } catch (e) { + message = e instanceof Error ? e.message : String(e); + } + observed.push({ name: c.name, message, sync: false }); + } + for (const c of voidCases) { + try { + c.thunk(); + observed.push({ name: c.name, message: 'no-throw', sync: false }); + } catch (e) { + observed.push({ name: c.name, message: e instanceof Error ? e.message : String(e), sync: true }); + } + } assert.deepStrictEqual( observed, - cases.map(c => ({ name: c.name, message: `TODO: Phase ${c.phase}` })), + [ + { name: 'abortSession', message: 'TODO: Phase 9', sync: false }, + { name: 'changeModel', message: 'TODO: Phase 9', sync: false }, + { name: 'respondToPermissionRequest', message: 'TODO: Phase 7', sync: true }, + ], ); }); @@ -412,6 +872,8 @@ suite('ClaudeAgent', () => { [ILogService, new NullLogService()], [ICopilotApiService, api], [IClaudeProxyService, proxy], + [ISessionDataService, createNullSessionDataService()], + [IClaudeAgentSdkService, new FakeClaudeAgentSdkService()], ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -429,4 +891,1430 @@ suite('ClaudeAgent', () => { await tick(); assert.deepStrictEqual(agent.models.get().map(m => m.id), [CLAUDE_SONNET.id]); }); + + // #region Phase 5 — session lifecycle + + test('createSession (non-fork) returns a claude:/ URI with provisional: true; no DB or SDK contact', async () => { + // Phase 6 §5.1 Test 1. Per-session DB is overlay/cache only and + // the SDK subprocess fork is deferred until first sendMessage. + // `provisional: true` opts the session into the AgentService's + // deferred-`sessionAdded` protocol. Workbench eagerly creates + // sessions on folder-pick + arms a 30s GC; for an empty Claude + // session that's a cheap in-memory drop because nothing has + // been persisted yet. + const { agent, sdk, sessionData } = createTestContext(disposables); + + const result = await agent.createSession({ workingDirectory: URI.parse('file:///workspace') }); + + assert.deepStrictEqual({ + scheme: result.session.scheme, + provider: AgentSession.provider(result.session), + isUuid: isUUID(AgentSession.id(result.session)), + workingDirectory: result.workingDirectory?.toString(), + provisional: result.provisional, + openDatabaseCalls: sessionData.openDatabaseCallCount, + tryOpenDatabaseCalls: sessionData.tryOpenDatabaseCallCount, + startupCallCount: sdk.startupCallCount, + listSessionsCallCount: sdk.listSessionsCallCount, + }, { + scheme: 'claude', + provider: 'claude', + isUuid: true, + workingDirectory: 'file:///workspace', + provisional: true, + openDatabaseCalls: 0, + tryOpenDatabaseCalls: 0, + startupCallCount: 0, + listSessionsCallCount: 0, + }); + }); + + test('createSession honors config.session when the workbench pre-mints the URI', async () => { + // Workbench eagerly mints the session URI client-side (PR #313841 + // folder-pick path) and round-trips it through createSession so + // the chat editor can render immediately. AgentService then + // double-checks the returned URI matches and surfaces "Agent + // host returned unexpected session URI" if the agent ignored + // the hint. Mirrors CopilotAgent's `config.session ? + // AgentSession.id(config.session) : generateUuid()` contract. + const { agent } = createTestContext(disposables); + const expected = AgentSession.uri('claude', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'); + + const result = await agent.createSession({ session: expected }); + + assert.deepStrictEqual({ + session: result.session.toString(), + provisional: result.provisional, + }, { + session: expected.toString(), + provisional: true, + }); + }); + + test('createSession({ fork }) throws TODO: Phase 6.5 with no side effects', async () => { + // Phase-6 update: fork is deferred to Phase 6.5 because Claude's + // `forkSession(sessionId, { upToMessageId })` takes a message UUID, + // not an event id, and the protocol-turn-ID → message-UUID + // translation needs `sdk.getSessionMessages` (also Phase 6.5). + // Locking the throw message here so a half-implementation can't + // land in Phase 6 without re-greening this case. + const { agent, sessionData, sdk } = createTestContext(disposables); + + await assert.rejects( + agent.createSession({ + fork: { + session: AgentSession.uri('claude', 'src-uuid'), + turnIndex: 0, + turnId: 'turn-1', + }, + }), + /Phase 6\.5/, + ); + + assert.deepStrictEqual({ + openDatabaseCalls: sessionData.openDatabaseCallCount, + tryOpenDatabaseCalls: sessionData.tryOpenDatabaseCallCount, + startupCallCount: sdk.startupCallCount, + listSessionsCallCount: sdk.listSessionsCallCount, + }, { + openDatabaseCalls: 0, + tryOpenDatabaseCalls: 0, + startupCallCount: 0, + listSessionsCallCount: 0, + }); + }); + + test('first sendMessage on a provisional session materializes it (single startup, single materialize event)', async () => { + // Phase 6 §5.1 Test 3 (tracer). Forces the materialize spine into + // existence: `_provisionalSessions` map, `_materializeProvisional`, + // `IClaudeAgentSdkService.startup()`, `_onDidMaterializeSession` + // event, and `entry.send` plumbing in `ClaudeAgentSession`. + // + // Public-interface assertions only: we never read `_sessions` + // or `_provisionalSessions` directly. The behavioral signature + // of "first send materializes" is: + // - SDK `startup()` is called exactly once (was 0 after + // createSession; is 1 after sendMessage). + // - The materialize event fires exactly once with the right URI. + // - The startup options carry the working directory the user + // picked at createSession time. + const { agent, sdk, proxy } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + assert.strictEqual(proxy.startCalls.length, 1, 'proxy started by authenticate'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + assert.strictEqual(sdk.startupCallCount, 0, 'createSession does not touch the SDK'); + + const events: IAgentMaterializeSessionEvent[] = []; + assert.ok(agent.onDidMaterializeSession, 'agent must expose onDidMaterializeSession'); + disposables.add(agent.onDidMaterializeSession(e => events.push(e))); + + const sessionId = AgentSession.id(created.session); + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), + makeResultSuccess(sessionId), + ]; + + await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + + assert.deepStrictEqual({ + startupCallCount: sdk.startupCallCount, + materializeEventCount: events.length, + eventSession: events[0]?.session.toString(), + eventCwd: events[0]?.workingDirectory?.fsPath, + startupOptionsCwd: sdk.capturedStartupOptions[0]?.cwd, + startupOptionsSessionId: sdk.capturedStartupOptions[0]?.sessionId, + }, { + startupCallCount: 1, + materializeEventCount: 1, + eventSession: created.session.toString(), + eventCwd: URI.file('/work').fsPath, + startupOptionsCwd: URI.file('/work').fsPath, + startupOptionsSessionId: sessionId, + }); + }); + + test('materialize event payload shape — { session, workingDirectory, project: undefined }', async () => { + // Phase 6 §5.1 Test 4. Pins the {@link IAgentMaterializeSessionEvent} + // payload independently of the tracer in Test 3. The default + // {@link createNoopGitService} produces no project metadata, so + // `project` is `undefined`. AgentService relies on this exact + // shape to forward to its `sessionAdded` notification (it spreads + // the event into `IAgentSessionMetadata`-shaped fields), so a + // snapshot here is the load-bearing contract. + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const cwd = URI.file('/payload-shape'); + const created = await agent.createSession({ workingDirectory: cwd }); + const sessionId = AgentSession.id(created.session); + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + + const events: IAgentMaterializeSessionEvent[] = []; + assert.ok(agent.onDidMaterializeSession); + disposables.add(agent.onDidMaterializeSession(e => events.push(e))); + + await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + + assert.strictEqual(events.length, 1, 'event fires exactly once'); + const ev = events[0]; + assert.deepStrictEqual({ + session: ev.session.toString(), + workingDirectory: ev.workingDirectory?.toString(), + project: ev.project, + keys: Object.keys(ev).sort(), + }, { + session: created.session.toString(), + workingDirectory: cwd.toString(), + project: undefined, + keys: ['project', 'session', 'workingDirectory'], + }); + }); + + test('two sendMessage calls reuse the materialized Query', async () => { + // Phase 6 §5.1 Test 5. After the first send materializes the + // session, subsequent sends MUST push onto the same prompt + // iterable / SDK Query — they MUST NOT re-fork the subprocess + // (`startup()` is expensive and would lose conversational state + // since the SDK's resume-from-session-id only kicks in on init). + // The invariants here are: (a) `startup()` is called exactly once + // across both turns, (b) `warm.query()` is bound exactly once, + // (c) both deferreds resolve on their respective `result` SDK + // messages, (d) both prompts traverse the prompt iterable. + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + + // Stage two turns. Park the iterator at index 2 (right after the + // first `result`) until the test releases it; this proves the + // second send reuses the same Query rather than spawning a new + // one (the gate would otherwise be irrelevant). Index choice + // mirrors plan §5.1 test 5. + const advance = new DeferredPromise(); + sdk.queryAdvance = async (idx: number) => { + if (idx === 2) { + await advance.p; + } + }; + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), + makeResultSuccess(sessionId), + makeResultSuccess(sessionId), + ]; + + // First turn — materializes; resolves on result(idx=1). + await agent.sendMessage(created.session, 'turn-1', undefined, 'turn-id-1'); + + // Snapshot before the second send so we can assert the second send + // did NOT call startup() again. + const startupCallsAfterTurn1 = sdk.startupCallCount; + const queryCallsAfterTurn1 = sdk.warmQueries[0]?.queryCallCount ?? -1; + + // Second turn — pushes onto the existing Query. + const p2 = agent.sendMessage(created.session, 'turn-2', undefined, 'turn-id-2'); + // Release the parked iterator so result(idx=2) flows through. + advance.complete(); + await p2; + + assert.deepStrictEqual({ + startupCallsAfterTurn1, + startupCallsAfterTurn2: sdk.startupCallCount, + queryCallsAfterTurn1, + queryCallsAfterTurn2: sdk.warmQueries[0]?.queryCallCount, + warmQueryCount: sdk.warmQueries.length, + drainedPromptCount: sdk.warmQueries[0]?.produced?.drainedPrompts.length, + }, { + startupCallsAfterTurn1: 1, + startupCallsAfterTurn2: 1, + queryCallsAfterTurn1: 1, + queryCallsAfterTurn2: 1, + warmQueryCount: 1, + drainedPromptCount: 2, + }); + }); + + test('text content_block emits SessionResponsePart(Markdown) before SessionDelta', async () => { + // Phase 6 §5.1 Test 6 + §3.6. The protocol reducer at + // `actions.ts:233 (SessionDelta)` requires the targeted + // `SessionResponsePart` to have already been emitted, otherwise + // the delta has nowhere to land. This test pins that ordering by + // staging a single text turn and asserting the first emitted + // `SessionResponsePart(Markdown, partId=X)` precedes every + // `SessionDelta(partId=X)` for the same X. The mapper allocates + // the partId on `content_block_start`, BEFORE any delta can + // arrive (deltas are SDK-ordered after the start), so the + // invariant holds by construction. + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), + makeStreamEvent(sessionId, makeMessageStart()), + makeStreamEvent(sessionId, makeContentBlockStartText(0)), + makeStreamEvent(sessionId, makeTextDelta(0, 'hello ')), + makeStreamEvent(sessionId, makeTextDelta(0, 'world')), + makeStreamEvent(sessionId, makeContentBlockStop(0)), + makeStreamEvent(sessionId, makeMessageStop()), + makeResultSuccess(sessionId), + ]; + + const signals: AgentSignal[] = []; + disposables.add(agent.onDidSessionProgress(s => signals.push(s))); + + await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + + const actionSignals = signals.filter(s => s.kind === 'action'); + const partActions = actionSignals + .map((s, i) => ({ s, i })) + .filter(({ s }) => s.kind === 'action' && s.action.type === ActionType.SessionResponsePart); + const deltaActions = actionSignals + .map((s, i) => ({ s, i })) + .filter(({ s }) => s.kind === 'action' && s.action.type === ActionType.SessionDelta); + + assert.strictEqual(partActions.length, 1, 'exactly one Markdown response part'); + assert.strictEqual(deltaActions.length, 2, 'two text deltas'); + + const part = partActions[0].s.kind === 'action' && partActions[0].s.action.type === ActionType.SessionResponsePart + ? partActions[0].s.action + : undefined; + const firstDelta = deltaActions[0].s.kind === 'action' && deltaActions[0].s.action.type === ActionType.SessionDelta + ? deltaActions[0].s.action + : undefined; + const secondDelta = deltaActions[1].s.kind === 'action' && deltaActions[1].s.action.type === ActionType.SessionDelta + ? deltaActions[1].s.action + : undefined; + + assert.ok(part, 'SessionResponsePart action present'); + assert.ok(firstDelta, 'first SessionDelta action present'); + assert.ok(secondDelta, 'second SessionDelta action present'); + assert.strictEqual(part.part.kind, ResponsePartKind.Markdown, 'part kind is Markdown'); + + assert.deepStrictEqual({ + partKindIsMarkdown: part.part.kind === ResponsePartKind.Markdown, + partPrecedesDelta: partActions[0].i < deltaActions[0].i, + partIdsMatch: part.part.id === firstDelta.partId && part.part.id === secondDelta.partId, + turnId: part.turnId, + deltaTexts: [firstDelta.content, secondDelta.content], + session: part.session.toString(), + }, { + partKindIsMarkdown: true, + partPrecedesDelta: true, + partIdsMatch: true, + turnId: 'turn-1', + deltaTexts: ['hello ', 'world'], + session: created.session.toString(), + }); + }); + + test('thinking content_block emits SessionResponsePart(Reasoning) before SessionReasoning', async () => { + // Phase 6 §5.1 Test 7. Same ordering invariant as Test 6 but for + // extended-thinking blocks: `SessionResponsePart(Reasoning)` MUST + // precede every `SessionReasoning(partId)` for the same partId + // (`actions.ts:540` reducer requires the part to exist). + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), + makeStreamEvent(sessionId, makeMessageStart()), + makeStreamEvent(sessionId, makeContentBlockStartThinking(0)), + makeStreamEvent(sessionId, makeThinkingDelta(0, 'let me think')), + makeStreamEvent(sessionId, makeThinkingDelta(0, ' more')), + makeStreamEvent(sessionId, makeContentBlockStop(0)), + makeStreamEvent(sessionId, makeMessageStop()), + makeResultSuccess(sessionId), + ]; + + const signals: AgentSignal[] = []; + disposables.add(agent.onDidSessionProgress(s => signals.push(s))); + + await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + + const actionSignals = signals.filter(s => s.kind === 'action'); + const partActions = actionSignals + .map((s, i) => ({ s, i })) + .filter(({ s }) => s.kind === 'action' && s.action.type === ActionType.SessionResponsePart); + const reasoningActions = actionSignals + .map((s, i) => ({ s, i })) + .filter(({ s }) => s.kind === 'action' && s.action.type === ActionType.SessionReasoning); + + const part = partActions[0]?.s.kind === 'action' && partActions[0].s.action.type === ActionType.SessionResponsePart + ? partActions[0].s.action + : undefined; + const firstReasoning = reasoningActions[0]?.s.kind === 'action' && reasoningActions[0].s.action.type === ActionType.SessionReasoning + ? reasoningActions[0].s.action + : undefined; + const secondReasoning = reasoningActions[1]?.s.kind === 'action' && reasoningActions[1].s.action.type === ActionType.SessionReasoning + ? reasoningActions[1].s.action + : undefined; + + assert.ok(part, 'SessionResponsePart action present'); + assert.ok(firstReasoning, 'first SessionReasoning action present'); + assert.ok(secondReasoning, 'second SessionReasoning action present'); + assert.ok(part.part.kind === ResponsePartKind.Reasoning, 'part kind is Reasoning'); + + assert.deepStrictEqual({ + partActionsCount: partActions.length, + reasoningActionsCount: reasoningActions.length, + partKindIsReasoning: part.part.kind === ResponsePartKind.Reasoning, + partPrecedesReasoning: partActions[0].i < reasoningActions[0].i, + partIdsMatch: part.part.id === firstReasoning.partId && part.part.id === secondReasoning.partId, + turnId: part.turnId, + reasoningTexts: [firstReasoning.content, secondReasoning.content], + }, { + partActionsCount: 1, + reasoningActionsCount: 2, + partKindIsReasoning: true, + partPrecedesReasoning: true, + partIdsMatch: true, + turnId: 'turn-1', + reasoningTexts: ['let me think', ' more'], + }); + }); + + test('result emits SessionUsage immediately before SessionTurnComplete', async () => { + // Phase 6 §5.1 Test 8 + §4 mapping table. The protocol contract + // requires usage to be reported BEFORE the turn is marked + // complete (otherwise consumers that flush state on + // `SessionTurnComplete` lose the usage attribution). Both + // signals come from the single `result` SDK message; the mapper + // emits them in the prescribed order. + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + const result = makeResultSuccess(sessionId); + // Override the zero-default usage with values the mapper must + // forward verbatim into `SessionUsage.usage`. + result.usage.input_tokens = 17; + result.usage.output_tokens = 42; + result.usage.cache_read_input_tokens = 5; + result.modelUsage = { + 'claude-sonnet-4-test': { + inputTokens: 17, + outputTokens: 42, + cacheReadInputTokens: 5, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0, + contextWindow: 200000, + maxOutputTokens: 8192, + }, + }; + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), result]; + + const signals: AgentSignal[] = []; + disposables.add(agent.onDidSessionProgress(s => signals.push(s))); + + await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + + const tail = signals + .map(s => s.kind === 'action' ? s.action : undefined) + .filter((a): a is NonNullable => + a?.type === ActionType.SessionUsage || a?.type === ActionType.SessionTurnComplete); + + const usage = tail[0]?.type === ActionType.SessionUsage ? tail[0] : undefined; + const complete = tail[1]?.type === ActionType.SessionTurnComplete ? tail[1] : undefined; + + assert.ok(usage, 'first action in tail is SessionUsage'); + assert.ok(complete, 'second action in tail is SessionTurnComplete'); + + assert.deepStrictEqual({ + tailLength: tail.length, + usageType: tail[0]?.type, + completeType: tail[1]?.type, + usageTurnId: usage.turnId, + completeTurnId: complete.turnId, + inputTokens: usage.usage.inputTokens, + outputTokens: usage.usage.outputTokens, + cacheReadTokens: usage.usage.cacheReadTokens, + model: usage.usage.model, + }, { + tailLength: 2, + usageType: ActionType.SessionUsage, + completeType: ActionType.SessionTurnComplete, + usageTurnId: 'turn-1', + completeTurnId: 'turn-1', + inputTokens: 17, + outputTokens: 42, + cacheReadTokens: 5, + model: 'claude-sonnet-4-test', + }); + }); + + test('multiple text blocks each get a distinct partId; deltas route correctly', async () => { + // Phase 6 §5.1 Test 9. Anthropic streams interleave text blocks + // (e.g. assistant emits two paragraphs in the same turn). Each + // `content_block_start` event has a distinct `index`; the mapper + // allocates a fresh partId per index and routes deltas via the + // `currentBlockParts` map. This test stages two text blocks at + // indices 0 and 1, sends a delta into each, and asserts the + // allocation produced two distinct partIds and the deltas + // landed on the right one. + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), + makeStreamEvent(sessionId, makeMessageStart()), + makeStreamEvent(sessionId, makeContentBlockStartText(0)), + makeStreamEvent(sessionId, makeTextDelta(0, 'first ')), + makeStreamEvent(sessionId, makeContentBlockStop(0)), + makeStreamEvent(sessionId, makeContentBlockStartText(1)), + makeStreamEvent(sessionId, makeTextDelta(1, 'second')), + makeStreamEvent(sessionId, makeContentBlockStop(1)), + makeStreamEvent(sessionId, makeMessageStop()), + makeResultSuccess(sessionId), + ]; + + const signals: AgentSignal[] = []; + disposables.add(agent.onDidSessionProgress(s => signals.push(s))); + + await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + + const partActions = signals + .map(s => s.kind === 'action' ? s.action : undefined) + .filter(a => a?.type === ActionType.SessionResponsePart); + const deltaActions = signals + .map(s => s.kind === 'action' ? s.action : undefined) + .filter(a => a?.type === ActionType.SessionDelta); + + const part0 = partActions[0]?.type === ActionType.SessionResponsePart ? partActions[0] : undefined; + const part1 = partActions[1]?.type === ActionType.SessionResponsePart ? partActions[1] : undefined; + const delta0 = deltaActions[0]?.type === ActionType.SessionDelta ? deltaActions[0] : undefined; + const delta1 = deltaActions[1]?.type === ActionType.SessionDelta ? deltaActions[1] : undefined; + + assert.ok(part0 && part1, 'two SessionResponsePart actions present'); + assert.ok(delta0 && delta1, 'two SessionDelta actions present'); + + const id0 = part0.part.kind === ResponsePartKind.Markdown ? part0.part.id : ''; + const id1 = part1.part.kind === ResponsePartKind.Markdown ? part1.part.id : ''; + + assert.deepStrictEqual({ + partActionsCount: partActions.length, + deltaActionsCount: deltaActions.length, + distinctPartIds: id0 !== id1, + delta0RoutedToPart0: delta0.partId === id0, + delta1RoutedToPart1: delta1.partId === id1, + delta0Content: delta0.content, + delta1Content: delta1.content, + }, { + partActionsCount: 2, + deltaActionsCount: 2, + distinctPartIds: true, + delta0RoutedToPart0: true, + delta1RoutedToPart1: true, + delta0Content: 'first ', + delta1Content: 'second', + }); + }); + + test('_isResumed flips on first system:init', async () => { + // Phase 6 §5.1 Test 10. The SDK's `system:init` message marks + // the start of a session. Phase 7+ teardown+recreate uses + // `_isResumed` to drive `Options.resume = sessionId` on the + // second `startup()`, signalling the SDK to reuse the existing + // transcript. Phase 6 has no teardown+recreate yet, so the test + // asserts the flag flip directly through a session getter. + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + + // Snapshot before the SDK has streamed any messages. + await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + + const session = agent.getSessionForTesting(created.session); + assert.ok(session, 'session is materialized'); + assert.strictEqual(session.isResumed, true, 'isResumed flipped after system:init'); + }); + + test('disposing a materialized session aborts the controller and rejects the in-flight send', async () => { + // Phase 6 §5.1 Test 11. The dispose chain registered in + // `ClaudeAgentSession`'s constructor calls + // `abortController.abort()`. The for-await loop sees + // `signal.aborted` and throws `CancellationError`, and the + // `_processMessages` catch latches `_fatalError` + rejects every + // in-flight deferred. Without the latch the in-flight send + // would park forever and the test would hang. + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + + // Park the iterator at index 0 so `_processMessages` is + // suspended inside `next()` when dispose runs. After dispose + // flips `signal.aborted`, releasing `advance` lets the + // for-await body run the `if (aborted) throw` check. + const advance = new DeferredPromise(); + sdk.queryAdvance = async (idx: number) => { + if (idx === 0) { + await advance.p; + } + }; + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), + makeResultSuccess(sessionId), + ]; + + // Use the materialize event to deterministically wait until the + // session is in `_sessions` (and the in-flight deferred has been + // queued by `entry.send`). Without this we'd race materialize. + const materialized = Event.toPromise(agent.onDidMaterializeSession); + + const send = agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + const settle: { rejected?: unknown } = {}; + const sendDone = send.then(() => { settle.rejected = false; }, err => { settle.rejected = err; }); + + await materialized; + // One additional macro-flush so `entry.send` has pushed the + // deferred to `_inFlightRequests` and `_processMessages` has + // started its for-await (parked on `advance.p`). + await new Promise(resolve => setImmediate(resolve)); + + const aborter = sdk.capturedStartupOptions[0]?.abortController; + await agent.disposeSession(created.session); + // Release the parked iterator so the for-await loop unblocks + // and the abort-check throws CancellationError. + advance.complete(); + await sendDone; + + assert.deepStrictEqual({ + rejectedIsCancellation: isCancellationError(settle.rejected), + abortedAfterDispose: aborter?.signal.aborted, + sessionRemoved: agent.getSessionForTesting(created.session) === undefined, + }, { + rejectedIsCancellation: true, + abortedAfterDispose: true, + sessionRemoved: true, + }); + }); + + test('dispose racing _writeCustomizationDirectory does not orphan the materialized session (C1)', async () => { + // Council-review C1 regression. The plan's Q8 belt-and-suspenders + // abort guard at `_materializeProvisional` only catches an abort + // that lands while `await sdk.startup()` is in flight. + // `_writeCustomizationDirectory` is a SECOND async boundary where + // a racing `disposeSession` (which uses `_disposeSequencer` — a + // different sequencer from `sendMessage`'s `_sessionSequencer`) + // can fire, find the provisional record, abort, remove, and + // return. Without the pre-commit abort gate added in this fix, + // materialize would still set `_sessions[sessionId]` and fire + // `onDidMaterializeSession` — leaking a WarmQuery subprocess. + // + // Test setup uses a custom session database whose `setMetadata` + // blocks on a per-test deferred so we can deterministically + // interleave dispose with persist. The fix asserts: + // - the racing `sendMessage` rejects with `CancellationError` + // - the session never lands in `_sessions` + // - `onDidMaterializeSession` never fires + // - the WarmQuery is asyncDisposed (no orphan subprocess) + const persistGate = new DeferredPromise(); + let persistEntered = false; + const blockingDb = new TestSessionDatabase(); + const originalSetMetadata = blockingDb.setMetadata.bind(blockingDb); + blockingDb.setMetadata = async (key, value) => { + persistEntered = true; + await persistGate.p; + await originalSetMetadata(key, value); + }; + + const proxy = new FakeClaudeProxyService(); + const api = new FakeCopilotApiService(); + api.models = async () => [...ALL_MODELS]; + const sdk = new FakeClaudeAgentSdkService(); + const sessionData = createSessionDataService(blockingDb); + + const services = new ServiceCollection( + [ILogService, new NullLogService()], + [ICopilotApiService, api], + [IClaudeProxyService, proxy], + [ISessionDataService, sessionData], + [IClaudeAgentSdkService, sdk], + [IAgentHostGitService, createNoopGitService()], + ); + const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); + const agent: ClaudeAgent = disposables.add(instantiationService.createInstance(ClaudeAgent)); + + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + + const materializeEvents: IAgentMaterializeSessionEvent[] = []; + disposables.add(agent.onDidMaterializeSession(e => materializeEvents.push(e))); + + // Kick off the materialize. It will pass the post-startup abort + // gate, create the wrapper, then park inside `setMetadata`. + const send = agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + const settle: { rejected?: unknown } = {}; + const sendDone = send.then(() => { settle.rejected = false; }, err => { settle.rejected = err; }); + + // Wait until the persist step has actually been entered. This is + // the deterministic gate — without it we'd be racing the materialize + // progress against our dispose call. + while (!persistEntered) { + await new Promise(resolve => setImmediate(resolve)); + } + + // Now dispose while persist is parked. The dispose-sequencer is + // independent of the send-sequencer, so this runs immediately: + // finds the provisional, aborts the controller, removes from + // `_provisionalSessions`, returns. + await agent.disposeSession(created.session); + + // Release the persist gate. Materialize resumes after the + // `await setMetadata`, hits the pre-commit abort gate (signal is + // aborted), disposes the wrapper, and throws CancellationError. + persistGate.complete(); + await sendDone; + + assert.deepStrictEqual({ + rejectedIsCancellation: isCancellationError(settle.rejected), + sessionNotInMap: agent.getSessionForTesting(created.session) === undefined, + materializeNeverFired: materializeEvents.length === 0, + warmQueryDisposed: sdk.warmQueries[0]?.asyncDisposeCount === 1, + }, { + rejectedIsCancellation: true, + sessionNotInMap: true, + materializeNeverFired: true, + warmQueryDisposed: true, + }); + }); + + test('disposing a provisional session never calls SDK startup and removes the record', async () => { + // Phase 6 §5.1 Test 12. Symmetric with createSession's + // "no SDK contact" invariant: provisional dispose must NOT + // reach `sdk.startup` (no subprocess spawn for an + // already-cancelled session). Pinned by: + // - `sdk.startupCallCount === 0` after dispose + // - a subsequent `sendMessage` for the same URI throws + // 'Cannot send to unknown session' (proves the provisional + // record was actually removed, not just abort-flagged) + // - the provisional's `AbortController` flipped to aborted + // (so any future racing materialize would short-circuit) + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + + await agent.disposeSession(created.session); + + // Materializing now requires a provisional record; without it + // the sequencer task throws synchronously inside the queued fn. + const sendErr = await agent.sendMessage(created.session, 'hi', undefined, 'turn-1') + .then(() => undefined, err => err); + + assert.deepStrictEqual({ + startupCallCount: sdk.startupCallCount, + warmQueriesLength: sdk.warmQueries.length, + sendThrewUnknown: sendErr instanceof Error && /unknown session/i.test(sendErr.message), + materializedAbsent: agent.getSessionForTesting(created.session) === undefined, + }, { + startupCallCount: 0, + warmQueriesLength: 0, + sendThrewUnknown: true, + materializedAbsent: true, + }); + }); + + test('shutdown drains a mix of provisional and materialized sessions', async () => { + // Phase 6 §5.1 Test 13. The shutdown spec is two-phase: + // 1) Provisional sessions: abort each AbortController + clear + // the map. No SDK contact (mirrors `disposeSession`'s + // provisional branch). This unblocks any racing + // `await sdk.startup()` so the materialize unwinds via the + // post-startup abort guard. + // 2) Materialized sessions: drain through the per-session + // `_disposeSequencer` so a concurrent caller targeting the + // same id is serialized; each entry's `dispose()` flips + // `signal.aborted` and asyncDisposes the WarmQuery. + // What this test pins: after `shutdown()`, every provisional + // AbortController is aborted, every materialized session has + // been removed from the map, and `shutdown()` is memoized + // (second call returns the same promise identity). + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + // Materialize one session by running a turn end-to-end. + const matCreated = await agent.createSession({ workingDirectory: URI.file('/work-mat') }); + sdk.nextQueryMessages = [ + makeSystemInitMessage(AgentSession.id(matCreated.session)), + makeResultSuccess(AgentSession.id(matCreated.session)), + ]; + await agent.sendMessage(matCreated.session, 'hi', undefined, 'turn-1'); + + // Leave a second session provisional. + const provCreated = await agent.createSession({ workingDirectory: URI.file('/work-prov') }); + const provAborter = (() => { + // The provisional's controller isn't directly observable from the + // public surface; capture it indirectly via the `capturedStartupOptions` + // of a hypothetical materialize. Since we never materialize the + // provisional here, we reach into the agent's test accessor: + const provSession = agent.getSessionForTesting(provCreated.session); + assert.strictEqual(provSession, undefined, 'second session must remain provisional'); + return undefined; + })(); + assert.strictEqual(provAborter, undefined); + + // Capture the materialized session's WarmQuery so we can assert + // it was asyncDisposed by shutdown. + const matWarm = sdk.warmQueries[0]; + assert.ok(matWarm, 'materialized session must have a WarmQuery'); + const asyncDisposeBefore = matWarm.asyncDisposeCount; + + const first = agent.shutdown(); + const second = agent.shutdown(); + await Promise.all([first, second]); + + assert.deepStrictEqual({ + memoized: first === second, + matRemoved: agent.getSessionForTesting(matCreated.session) === undefined, + matWarmAsyncDisposed: matWarm.asyncDisposeCount > asyncDisposeBefore, + // A post-shutdown sendMessage to the provisional URI must + // fail because the provisional record was cleared. + provDropped: await agent.sendMessage(provCreated.session, 'late', undefined, 'turn-late') + .then(() => false, err => err instanceof Error && /unknown session/i.test(err.message)), + // Same for the materialized URI. + matDropped: await agent.sendMessage(matCreated.session, 'late', undefined, 'turn-late') + .then(() => false, err => err instanceof Error && /unknown session/i.test(err.message)), + }, { + memoized: true, + matRemoved: true, + matWarmAsyncDisposed: true, + provDropped: true, + matDropped: true, + }); + }); + + test('mapper throwing on a malformed stream_event is logged and the turn continues', async () => { + // Phase 6 §5.1 Test 14. The mapper does its OWN warn-and-skip + // for known malformed shapes (e.g. tool_use streams while + // `canUseTool: deny`). The try/catch in `_processMessages` is + // defense-in-depth for everything else: a programming bug in + // the mapper, an SDK output we didn't anticipate, etc. This + // test pins that resilience guarantee — pass an event that + // makes the mapper crash on field access (`event.delta.type` + // when `delta` is missing), then verify: + // 1) the catch absorbs the throw (turn doesn't reject), + // 2) the next valid stream event still flows through (the + // mapper state isn't poisoned), + // 3) the result message still completes the deferred. + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + + const sessionUri = created.session; + const observed: AgentSignal[] = []; + disposables.add(agent.onDidSessionProgress(s => { + if (AgentSession.id(s.session) === AgentSession.id(sessionUri)) { + observed.push(s); + } + })); + + // Build a `content_block_delta` event missing the required + // `delta` field. The malformed event is typed as + // `BetaRawContentBlockDeltaEvent` via `// @ts-expect-error` + // rather than a cast — keeps the type system honest about the + // shape while still letting the runtime exercise the mapper's + // defensive try/catch. + const malformedDeltaEvent = { type: 'content_block_delta', index: 0 }; + // @ts-expect-error - intentionally missing `delta` field to test mapper resilience + const malformedEvent: BetaRawContentBlockDeltaEvent = malformedDeltaEvent; + const malformedMessage = makeStreamEvent(sessionId, malformedEvent); + + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), + makeStreamEvent(sessionId, makeMessageStart()), + makeStreamEvent(sessionId, makeContentBlockStartText(0)), + malformedMessage, + makeStreamEvent(sessionId, makeTextDelta(0, 'recover')), + makeStreamEvent(sessionId, makeContentBlockStop(0)), + makeResultSuccess(sessionId), + ]; + + await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + + const deltas = observed.flatMap(s => + s.kind === 'action' && s.action.type === ActionType.SessionDelta + ? [s.action.content] + : []); + const turnCompletes = observed.filter(s => + s.kind === 'action' && s.action.type === ActionType.SessionTurnComplete); + + assert.deepStrictEqual({ + deltas, + turnCompleteCount: turnCompletes.length, + }, { + deltas: ['recover'], + turnCompleteCount: 1, + }); + }); + + test('attachments (File and Directory) become a system-reminder block on the user message', async () => { + // Phase 6 §5.1 Test 15. The prompt resolver must produce two + // content blocks for an attachment-bearing send: a `text` + // block carrying the prompt, then a `text` block wrapped in + // `` listing the attached URIs (one line + // per entry, prefix `- `, paths via fsPath for `file:` URIs). + // Phase 6 only round-trips File and Directory — the Selection + // branch is dead-code (AgentSideEffects strips text/selection + // at the protocol → agent boundary). + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), + makeResultSuccess(sessionId), + ]; + + const fileUri = URI.file('/work/src/foo.ts'); + const dirUri = URI.file('/work/src/bar'); + await agent.sendMessage(created.session, 'review please', [ + { type: AttachmentType.File, uri: fileUri, displayName: 'foo.ts' }, + { type: AttachmentType.Directory, uri: dirUri, displayName: 'bar' }, + ], 'turn-1'); + + const drained = sdk.warmQueries[0]?.produced?.drainedPrompts ?? []; + assert.strictEqual(drained.length, 1, 'one prompt was drained'); + const userMessage = drained[0]; + const content = userMessage.message.content; + assert.ok(Array.isArray(content), 'content blocks are an array'); + + assert.deepStrictEqual({ + blockCount: content.length, + promptText: content[0]?.type === 'text' ? content[0].text : undefined, + reminderText: content[1]?.type === 'text' ? content[1].text : undefined, + }, { + blockCount: 2, + promptText: 'review please', + reminderText: + '\nThe user provided the following references:\n' + + `- ${fileUri.fsPath}\n` + + `- ${dirUri.fsPath}\n\n` + + 'IMPORTANT: this context may or may not be relevant to your tasks. ' + + 'You should not respond to this context unless it is highly relevant to your task.\n' + + '', + }); + }); + + test('shutdown resolves without throwing', async () => { + const { agent } = createTestContext(disposables); + await agent.shutdown(); + }); + + test('disposeSession is a safe no-op for an unknown session', async () => { + const { agent } = createTestContext(disposables); + await agent.disposeSession(URI.parse('claude:/never-created')); + }); + + test('shutdown clears provisional sessions; concurrent disposeSession is safe', async () => { + // Phase-6 update: createSession is provisional, so no + // `ClaudeAgentSession` wrappers exist before the first + // `sendMessage`. The wrapper-disposal-once invariant moves to + // the materialized-session shutdown drain in Cycle 13 (§5.1 + // Test 13). What this test still pins: shutdown + a concurrent + // `disposeSession` for a provisional URI complete without + // throwing, both share the `_disposeSequencer` for the same + // key, and the agent does not surface a double-dispose error. + const { agent } = createTestContext(disposables); + const r1 = await agent.createSession({}); + await agent.createSession({}); + + const p1 = agent.disposeSession(r1.session); + const p2 = agent.shutdown(); + await Promise.all([p1, p2]); + + // `shutdown` is memoized — a second call returns the same + // promise. Pin that here so concurrent teardowns don't double-drain. + const third = agent.shutdown(); + assert.strictEqual(third, p2); + await third; + }); + + test('disposeSession removes the wrapper but does NOT delete the SDK or DB session', async () => { + // Plan section 3.3.4 — `disposeSession` is wrapper teardown, NOT + // session deletion. The SDK session and the per-session DB + // outlive `disposeSession`; permanent deletion is a Phase 13 + // concern (deletion command) and goes through a different code + // path. The user-visible consequence: closing a tab in the + // workbench drops the wrapper but the session reappears in the + // session list (and its history is still on disk) until + // explicitly deleted. This invariant prevents accidental + // regression in Phase 6+ where wrapper teardown will gain real + // cleanup work (Query.interrupt) — that work MUST NOT spill + // into SDK-side or DB-side deletion. + const { agent, sdk } = createTestContext(disposables); + const created = await agent.createSession({}); + // Make the SDK report the just-created session as if its + // metadata had been written by an earlier `query()` turn — + // that's the steady state once Phase 6 sendMessage lands. + sdk.sessionList = [{ + sessionId: AgentSession.id(created.session), + summary: 'Hello world', + lastModified: 100, + }]; + + await agent.disposeSession(created.session); + const result = await agent.listSessions(); + + assert.deepStrictEqual({ + ids: result.map(r => AgentSession.id(r.session)), + summary: result[0]?.summary, + sdkCalls: sdk.listSessionsCallCount, + }, { + ids: [AgentSession.id(created.session)], + summary: 'Hello world', + sdkCalls: 1, + }); + }); + + test('getSessionMessages returns an empty transcript for any session', async () => { + // Phase 5 doesn't reconstruct transcripts. Real history reconstruction + // from the SDK event log lands in Phase 13; the bare method shape is + // required by IAgent so callers can subscribe before any messages + // exist. Returning `[]` is correct: the agent service supplies its + // own provisional turns from in-memory state until this method + // surfaces the persisted log. We assert the result is also a fresh + // array (not a shared sentinel) so future implementations can't + // leak mutations. + const { agent } = createTestContext(disposables); + const a = await agent.getSessionMessages(URI.parse('claude:/unknown-1')); + const b = await agent.getSessionMessages(URI.parse('claude:/unknown-2')); + assert.deepStrictEqual({ a, b, distinct: a !== b }, { a: [], b: [], distinct: true }); + }); + + test('listSessions returns SDK entries decorated with the per-session DB overlay', async () => { + // Plan section 3.3.2: the SDK is the source of truth; the per-session DB + // is a pure overlay/cache. We seed two SDK entries and a single + // DB carrying `claude.customizationDirectory` for entry 'a'. The + // result must include both entries; the overlay value must + // surface only on the entry that has a DB. + const dbA = new TestSessionDatabase(); + await dbA.setMetadata('claude.customizationDirectory', URI.file('/foo').toString()); + + const sessionData: ISessionDataService = { + ...createNullSessionDataService(), + tryOpenDatabase: async session => { + if (AgentSession.id(session) === 'a') { + return { object: dbA, dispose: () => { /* no-op */ } }; + } + return undefined; + }, + }; + const sdk = new FakeClaudeAgentSdkService(); + sdk.sessionList = [ + { sessionId: 'a', summary: 'Session A', lastModified: 1000, createdAt: 900 }, + { sessionId: 'b', summary: 'Session B', lastModified: 2000, createdAt: 1900 }, + ]; + + const services = new ServiceCollection( + [ILogService, new NullLogService()], + [ICopilotApiService, new FakeCopilotApiService()], + [IClaudeProxyService, new FakeClaudeProxyService()], + [ISessionDataService, sessionData], + [IClaudeAgentSdkService, sdk], + ); + const instantiationService = disposables.add(new InstantiationService(services)); + const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); + + const result = await agent.listSessions(); + const a = result.find(r => AgentSession.id(r.session) === 'a'); + const b = result.find(r => AgentSession.id(r.session) === 'b'); + assert.deepStrictEqual({ + count: result.length, + ids: result.map(r => AgentSession.id(r.session)).sort(), + summaryA: a?.summary, + summaryB: b?.summary, + modifiedA: a?.modifiedTime, + modifiedB: b?.modifiedTime, + custDirA: a?.customizationDirectory?.toString(), + custDirB: b?.customizationDirectory, + sdkCalls: sdk.listSessionsCallCount, + }, { + count: 2, + ids: ['a', 'b'], + summaryA: 'Session A', + summaryB: 'Session B', + modifiedA: 1000, + modifiedB: 2000, + custDirA: URI.file('/foo').toString(), + custDirB: undefined, + sdkCalls: 1, + }); + }); + + test('listSessions tolerates a corrupt DB without poisoning the rest of the listing', async () => { + // Plan section 3.3.2 risk: a single corrupt per-session DB MUST NOT + // drop the other entries from the listing. CopilotAgent's + // `Promise.all`-with-throwing-mapper pattern at copilotAgent.ts:519 + // has this latent bug; we follow AgentService.listSessions's + // inner-try/catch pattern instead. We simulate the failure by + // rejecting `tryOpenDatabase` for one specific sessionId; the + // other two must still surface, and the corrupt one must fall + // back to the bare SDK-derived entry (NOT undefined / NOT + // dropped). + const dbOk = new TestSessionDatabase(); + await dbOk.setMetadata('claude.customizationDirectory', URI.file('/ok').toString()); + + const sessionData: ISessionDataService = { + ...createNullSessionDataService(), + tryOpenDatabase: async session => { + const id = AgentSession.id(session); + if (id === 'corrupt') { + throw new Error('simulated DB open failure'); + } + if (id === 'ok') { + return { object: dbOk, dispose: () => { /* no-op */ } }; + } + return undefined; + }, + }; + const sdk = new FakeClaudeAgentSdkService(); + sdk.sessionList = [ + { sessionId: 'ok', summary: 'OK', lastModified: 100 }, + { sessionId: 'corrupt', summary: 'Corrupt', lastModified: 200 }, + { sessionId: 'external', summary: 'External', lastModified: 300 }, + ]; + + const services = new ServiceCollection( + [ILogService, new NullLogService()], + [ICopilotApiService, new FakeCopilotApiService()], + [IClaudeProxyService, new FakeClaudeProxyService()], + [ISessionDataService, sessionData], + [IClaudeAgentSdkService, sdk], + ); + const instantiationService = disposables.add(new InstantiationService(services)); + const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); + + const result = await agent.listSessions(); + const find = (id: string) => result.find(r => AgentSession.id(r.session) === id); + assert.deepStrictEqual({ + count: result.length, + ids: result.map(r => AgentSession.id(r.session)).sort(), + okCustDir: find('ok')?.customizationDirectory?.toString(), + corruptCustDir: find('corrupt')?.customizationDirectory, + corruptSummary: find('corrupt')?.summary, + externalCustDir: find('external')?.customizationDirectory, + }, { + count: 3, + ids: ['corrupt', 'external', 'ok'], + okCustDir: URI.file('/ok').toString(), + corruptCustDir: undefined, + corruptSummary: 'Corrupt', + externalCustDir: undefined, + }); + }); + + test('listSessions returns an empty list (does not reject) when the SDK fails to load', async () => { + // Copilot-reviewer comment: `AgentService.listSessions` fans out + // across providers via `Promise.all` (agentService.ts:202-204). + // If our SDK dynamic import rejects (corrupt install, missing + // optional dep) and we let it propagate, every other provider's + // session list disappears too \u2014 the sibling Copilot provider + // goes blank. Catching here keeps Claude's row empty while + // Copilot's row still surfaces. + const sdk = new FakeClaudeAgentSdkService(); + sdk.listSessionsRejection = new Error('simulated SDK load failure'); + + const services = new ServiceCollection( + [ILogService, new NullLogService()], + [ICopilotApiService, new FakeCopilotApiService()], + [IClaudeProxyService, new FakeClaudeProxyService()], + [ISessionDataService, createNullSessionDataService()], + [IClaudeAgentSdkService, sdk], + ); + const instantiationService = disposables.add(new InstantiationService(services)); + const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); + + const result = await agent.listSessions(); + assert.deepStrictEqual(result, []); + }); + + test('shutdown is idempotent and returns the same memoized promise on concurrent calls', async () => { + // Phase 6+ INVARIANT: the SDK Query subprocess for each live + // session is aborted inside `shutdown()`. If two callers race + // (e.g. ChatService.onDidShutdown + the host's own teardown), + // they MUST share one drain pass — otherwise we double-abort + // and risk EBUSY on the SQLite handle. Phase 5 has no async + // work yet, so the race is benign in practice; the memoization + // is locked NOW so Phase 6 inherits the contract for free. + // Mirror of `CopilotAgent.shutdown()` at copilotAgent.ts:1246. + const { agent } = createTestContext(disposables); + await agent.createSession({}); + await agent.createSession({}); + + const first = agent.shutdown(); + const second = agent.shutdown(); + await Promise.all([first, second]); + const third = agent.shutdown(); + await third; + + assert.deepStrictEqual({ + firstEqualsSecond: first === second, + firstEqualsThird: first === third, + }, { + firstEqualsSecond: true, + firstEqualsThird: true, + }); + }); + + test('ClaudeAgentSdkService caches the resolved module and logs the first load failure exactly once', async () => { + // Plan section 3.1 risk: a corrupt postinstall (missing native binding, + // bad node_modules) will fault every `import()` call. We MUST + // surface the first failure clearly so it's diagnosable, but + // MUST NOT spam the log on every subsequent call (listSessions + // runs per workbench refresh and per session-list rerender). + // Successful resolution is also cached so the dynamic import + // runs only once across the lifetime of the host. + // + // We drive this via a `TestableClaudeAgentSdkService` that + // overrides the protected `_loadSdk` seam — the production code + // returns the narrowed `IClaudeSdkBindings` slice rather than + // the full SDK module type, so the test can build a fake + // without naming every export. A `RecordingLogService` captures + // `error()` invocations. + const errorCalls: unknown[][] = []; + class RecordingLogService extends NullLogService { + override error(...args: unknown[]): void { + errorCalls.push(args); + } + } + + let importBehavior: 'fail' | IClaudeSdkBindings = 'fail'; + let importInvocations = 0; + class TestableClaudeAgentSdkService extends ClaudeAgentSdkService { + protected override async _loadSdk(): Promise { + importInvocations++; + if (importBehavior === 'fail') { + throw new Error('simulated SDK load failure'); + } + return importBehavior; + } + } + + const services = new ServiceCollection([ILogService, new RecordingLogService()]); + const inst = disposables.add(new InstantiationService(services)); + const svc = inst.createInstance(TestableClaudeAgentSdkService); + + // First two calls fault → exactly one log entry; both retry the import. + await assert.rejects(() => svc.listSessions(), /simulated SDK load failure/); + await assert.rejects(() => svc.listSessions(), /simulated SDK load failure/); + const failuresLogged = errorCalls.length; + const importInvocationsAfterFailures = importInvocations; + + // Recover. + importBehavior = { + listSessions: async () => [{ sessionId: 's', summary: 's', lastModified: 1 }], + startup: async () => { throw new Error('TestableClaudeAgentSdkService: startup not modeled'); }, + }; + const result1 = await svc.listSessions(); + const importInvocationsAfterFirstSuccess = importInvocations; + + // Subsequent successful calls hit the cache. + const result2 = await svc.listSessions(); + + assert.deepStrictEqual({ + failuresLogged, + importInvocationsAfterFailures, + importInvocationsAfterFirstSuccess, + invocationsAfterCachedCall: importInvocations, + result1Length: result1.length, + result1Id: result1[0]?.sessionId, + result2Length: result2.length, + finalLogCount: errorCalls.length, + }, { + failuresLogged: 1, + importInvocationsAfterFailures: 2, + importInvocationsAfterFirstSuccess: 3, + invocationsAfterCachedCall: 3, + result1Length: 1, + result1Id: 's', + result2Length: 1, + finalLogCount: 1, + }); + }); + + test('resolveSessionConfig returns Claude-native permissionMode + reused Permissions schema', async () => { + // Plan section 3.3.5 / decision B5 — Claude collapses the platform's + // two-axis approval model (`autoApprove` × `mode`) onto a single + // `permissionMode` axis matching the SDK's native + // `PermissionMode` enum. `Permissions` (allow/deny tool lists) + // is reused unchanged from `platformSessionSchema` because the + // SDK accepts `allowedTools` / `disallowedTools` natively. + // Tested keys: presence + ordering of enum + the four-value + // canonical set + default. Skipped keys (AutoApprove, Mode, + // Isolation, Branch, BranchNameHint) MUST be absent — workbench + // `AgentHostModePicker` and friends key off these property names + // to decide what to render, and accidentally re-introducing + // `mode` would drop the wrong picker into the Claude UI. + const { agent } = createTestContext(disposables); + const result = await agent.resolveSessionConfig({}); + const properties = result.schema.properties; + const permissionMode = properties['permissionMode']; + + assert.deepStrictEqual({ + topLevelType: result.schema.type, + propertyKeys: Object.keys(properties).sort(), + permissionModeType: permissionMode?.type, + permissionModeEnum: permissionMode?.enum, + permissionModeDefault: permissionMode?.default, + permissionsType: properties['permissions']?.type, + values: result.values, + autoApproveAbsent: properties['autoApprove'] === undefined, + modeAbsent: properties['mode'] === undefined, + isolationAbsent: properties['isolation'] === undefined, + branchAbsent: properties['branch'] === undefined, + }, { + topLevelType: 'object', + propertyKeys: ['permissionMode', 'permissions'], + permissionModeType: 'string', + permissionModeEnum: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], + permissionModeDefault: 'default', + permissionsType: 'object', + values: { permissionMode: 'default' }, + autoApproveAbsent: true, + modeAbsent: true, + isolationAbsent: true, + branchAbsent: true, + }); + }); + + test('sessionConfigCompletions returns no items (permissionMode is a static enum)', async () => { + // Plan section 3.3.5 — Claude's only schema property is the + // `permissionMode` static enum, so dynamic completion is + // definitionally empty. Locks the contract before Phase 6's + // branch picker (subject to the worktree-extraction prerequisite + // in section 8) might want to plug into this method. + const { agent } = createTestContext(disposables); + const result = await agent.sessionConfigCompletions({ property: 'permissionMode', query: 'def' }); + assert.deepStrictEqual(result, { items: [] }); + }); + + test('dispose releases the proxy handle even with no materialized sessions', async () => { + // Phase-6 update: the wrapper-before-proxy ordering invariant + // only applies once a session has been materialized — provisional + // sessions hold no SDK subprocess that talks to the proxy. The + // wrapper-before-proxy ordering test moves to Cycle 11 (§5.1 + // Test 11 — dispose materialized aborts controller). What this + // test still pins for Phase 6: dispose releases the proxy handle + // even if no session was ever materialized, so authenticated-but- + // unused agents don't leak the proxy refcount. + let proxyDisposed = false; + + class RecordingProxyService implements IClaudeProxyService { + declare readonly _serviceBrand: undefined; + async start(_token: string): Promise { + return { + baseUrl: 'http://127.0.0.1:0', + nonce: 'n', + dispose: () => { proxyDisposed = true; }, + }; + } + dispose(): void { /* no-op */ } + } + + const services = new ServiceCollection( + [ILogService, new NullLogService()], + [ICopilotApiService, new FakeCopilotApiService()], + [IClaudeProxyService, new RecordingProxyService()], + [ISessionDataService, createNullSessionDataService()], + [IClaudeAgentSdkService, new FakeClaudeAgentSdkService()], + ); + const instantiationService = disposables.add(new InstantiationService(services)); + const agent = instantiationService.createInstance(ClaudeAgent); + + await agent.authenticate('https://api.github.com', 'tok'); + await agent.createSession({}); + agent.dispose(); + + assert.strictEqual(proxyDisposed, true); + }); + + test('agent.dispose() during a racing first sendMessage aborts the provisional and disposes the WarmQuery', async () => { + // Copilot reviewer: `dispose()` did not abort provisional + // AbortControllers. If a `sendMessage` was racing materialize + // (parked inside `_writeCustomizationDirectory`), `dispose()` + // would synchronously dispose `_sessions` and remove provisional + // records via teardown — but the materialize sequencer + // continuation, having already passed the post-startup abort + // gate, would resume past the persist step and call + // `_sessions.set(...)` on an already-disposed DisposableMap, + // orphaning the WarmQuery subprocess. The fix adds a + // `provisional.abortController.abort()` step before + // `super.dispose()` so the post-customization-write abort gate + // catches the race and asyncDisposes the WarmQuery. + const persistGate = new DeferredPromise(); + let persistEntered = false; + const blockingDb = new TestSessionDatabase(); + const originalSetMetadata = blockingDb.setMetadata.bind(blockingDb); + blockingDb.setMetadata = async (key, value) => { + persistEntered = true; + await persistGate.p; + await originalSetMetadata(key, value); + }; + + const proxy = new FakeClaudeProxyService(); + const api = new FakeCopilotApiService(); + api.models = async () => [...ALL_MODELS]; + const sdk = new FakeClaudeAgentSdkService(); + const sessionData = createSessionDataService(blockingDb); + + const services = new ServiceCollection( + [ILogService, new NullLogService()], + [ICopilotApiService, api], + [IClaudeProxyService, proxy], + [ISessionDataService, sessionData], + [IClaudeAgentSdkService, sdk], + [IAgentHostGitService, createNoopGitService()], + ); + const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); + const agent: ClaudeAgent = instantiationService.createInstance(ClaudeAgent); + + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + + const send = agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + const settle: { rejected?: unknown } = {}; + const sendDone = send.then(() => { settle.rejected = false; }, err => { settle.rejected = err; }); + + while (!persistEntered) { + await new Promise(resolve => setImmediate(resolve)); + } + + // Now dispose the WHOLE AGENT while persist is parked. This is + // the path the reviewer flagged: provisional AbortController + // must be aborted so the post-customization-write gate catches. + agent.dispose(); + + persistGate.complete(); + await sendDone; + + assert.deepStrictEqual({ + rejectedIsCancellation: isCancellationError(settle.rejected), + warmQueryDisposed: sdk.warmQueries[0]?.asyncDisposeCount === 1, + }, { + rejectedIsCancellation: true, + warmQueryDisposed: true, + }); + }); + + // #endregion }); diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 757a1701f658c..207611ee1d661 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -9,7 +9,7 @@ import { Schemas } from '../../../../base/common/network.js'; import { isMacintosh } from '../../../../base/common/platform.js'; import { PolicyCategory } from '../../../../base/common/policy.js'; import { CopilotSessionSearchPolicy } from '../../../../base/common/defaultAccount.js'; -import { AgentHostClaudeAgentEnabledSettingId, AgentHostEnabledSettingId, AgentHostIpcLoggingSettingId } from '../../../../platform/agentHost/common/agentService.js'; +import { AgentHostClaudeAgentSdkPathSettingId, AgentHostEnabledSettingId, AgentHostIpcLoggingSettingId } from '../../../../platform/agentHost/common/agentService.js'; import { AgentNetworkFilterService, IAgentNetworkFilterService } from '../../../../platform/networkFilter/common/networkFilterService.js'; import { AgentNetworkDomainSettingId } from '../../../../platform/networkFilter/common/settings.js'; import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../platform/sandbox/common/settings.js'; @@ -986,10 +986,10 @@ configurationRegistry.registerConfiguration({ tags: ['experimental', 'advanced'], included: product.quality !== 'stable', }, - [AgentHostClaudeAgentEnabledSettingId]: { - type: 'boolean', - description: nls.localize('chat.agentHost.claudeAgent.enabled', "When enabled, the Claude agent provider is registered inside the agent host. Requires `#chat.agentHost.enabled#`. The agent host process must be restarted for changes to this setting to take effect."), - default: false, + [AgentHostClaudeAgentSdkPathSettingId]: { + type: 'string', + description: nls.localize('chat.agentHost.claudeAgent.path', "Experimental, for local testing only. Absolute path to a locally-installed `@anthropic-ai/claude-agent-sdk` package. When set, the Claude agent provider is registered inside the agent host and the SDK is loaded from this path. Requires `#chat.agentHost.enabled#`. The agent host process must be restarted for changes to take effect. This setting will be removed once the SDK is delivered through the Extension Marketplace."), + default: '', tags: ['experimental', 'advanced'], included: product.quality !== 'stable', }, From e1615a45e22d64b68b2b447405fb5d1dac8f27aa Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 6 May 2026 16:56:25 -0700 Subject: [PATCH 32/34] Agent host: prefer origin/ as worktree base (#314861) Agent host: prefer origin/ for worktree base Match the extension-host CLI behaviour of preferring the remote-tracking ref over a possibly-stale local branch when picking the worktree start point, and pass --no-track to git worktree add so the new agent branch does not pick up upstream tracking from the start point. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/agentHostGitService.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index ec21c9da49371..ed1135d225832 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -151,10 +151,19 @@ export class AgentHostGitService implements IAgentHostGitService { } const branch = remoteRef.substring('refs/remotes/origin/'.length); - // Check whether a local branch exists; if not, use the remote-tracking ref - // so that 'git worktree add ... ' resolves correctly. + // Prefer the remote-tracking ref ('origin/') over the local + // branch when both exist, so worktrees are based on the most + // up-to-date commit rather than a possibly stale local branch. + // This mirrors the extension-host CLI which resolves a branch's + // upstream and uses that as the worktree start point. Falls back + // to the local branch when the remote-tracking ref is missing + // (e.g. fresh clone with no remote-tracking refs yet). + const hasRemoteRef = (await this._runGit(workingDirectory, ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${branch}`])) !== undefined; + if (hasRemoteRef) { + return `origin/${branch}`; + } const hasLocalBranch = (await this._runGit(workingDirectory, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`])) !== undefined; - return hasLocalBranch ? branch : `origin/${branch}`; + return hasLocalBranch ? branch : undefined; } return undefined; } @@ -187,7 +196,11 @@ export class AgentHostGitService implements IAgentHostGitService { } async addWorktree(repositoryRoot: URI, worktree: URI, branchName: string, startPoint: string): Promise { - await this._runGit(repositoryRoot, ['worktree', 'add', '-b', branchName, worktree.fsPath, startPoint], { timeout: 30_000, throwOnError: true }); + // Pass --no-track so the new agent branch never picks up upstream + // tracking from the start point (e.g. when starting from + // 'origin/main', without --no-track git would set the new branch's + // upstream to origin/main, which would mis-attribute pushes/pulls). + await this._runGit(repositoryRoot, ['worktree', 'add', '--no-track', '-b', branchName, worktree.fsPath, startPoint], { timeout: 30_000, throwOnError: true }); } async addExistingWorktree(repositoryRoot: URI, worktree: URI, branchName: string): Promise { From e5ed664943fb68635350d118cdaf223df889b757 Mon Sep 17 00:00:00 2001 From: Paul Date: Wed, 6 May 2026 17:00:18 -0700 Subject: [PATCH 33/34] Update look of model hover (#314820) --- .../actionWidget/browser/actionList.ts | 45 +++--- .../browser/widget/input/chatModelPicker.ts | 129 ++++++++++++++---- .../chat/browser/widget/media/chat.css | 76 +++++++++++ 3 files changed, 206 insertions(+), 44 deletions(-) diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 5e8a2cbe026a9..9a3dfa6590812 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -17,7 +17,7 @@ import { Emitter } from '../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../base/common/htmlContent.js'; import { ResolvedKeybinding } from '../../../base/common/keybindings.js'; import { AnchorPosition } from '../../../base/common/layout.js'; -import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { OS } from '../../../base/common/platform.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import { URI } from '../../../base/common/uri.js'; @@ -47,9 +47,13 @@ export interface IActionListDelegate { */ export interface IActionListItemHover { /** - * Content to display in the hover. + * Content to display in the hover. Can be a markdown string or an HTMLElement for full DOM control. */ - readonly content?: string | MarkdownString; + readonly content?: string | MarkdownString | HTMLElement; + /** + * Optional disposable associated with the hover content (e.g. from rendered markdown). + */ + readonly disposable?: IDisposable; } export interface IActionListItem { @@ -1350,20 +1354,27 @@ export class ActionListWidget extends Disposable { let hoverHeader: HTMLElement | undefined; const hoverContent = element.hover?.content; if (hoverContent) { - const markdown = typeof hoverContent === 'string' ? new MarkdownString(hoverContent) : hoverContent; - const linkHandler = this._options?.linkHandler; - const rendered = renderMarkdown(markdown, { - actionHandler: (url: string) => { - const uri = URI.parse(url); - if (linkHandler) { - linkHandler(uri, element); - } else { - this._openerService.open(uri, { allowCommands: true }); - } - }, - }); - this._submenuDisposables.add(rendered); - hoverHeader = rendered.element; + if (dom.isHTMLElement(hoverContent)) { + hoverHeader = hoverContent; + if (element.hover?.disposable) { + this._submenuDisposables.add(element.hover.disposable); + } + } else { + const markdown = typeof hoverContent === 'string' ? new MarkdownString(hoverContent) : hoverContent; + const linkHandler = this._options?.linkHandler; + const rendered = renderMarkdown(markdown, { + actionHandler: (url: string) => { + const uri = URI.parse(url); + if (linkHandler) { + linkHandler(uri, element); + } else { + this._openerService.open(uri, { allowCommands: true }); + } + }, + }); + this._submenuDisposables.add(rendered); + hoverHeader = rendered.element; + } hoverHeader.classList.add('action-list-submenu-hover-header'); if (element.submenuActions?.length) { hoverHeader.classList.add('has-submenu'); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index ebaeaf89751ef..53dcde6f5bae0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -5,6 +5,7 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js'; +import { renderMarkdown } from '../../../../../../base/browser/markdownRenderer.js'; import { renderIcon } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { getBaseLayerHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegate2.js'; import { getDefaultHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; @@ -14,7 +15,7 @@ import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { KeyCode } from '../../../../../../base/common/keyCodes.js'; -import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { autorun, IObservable } from '../../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -103,8 +104,9 @@ function createModelItem( action: IActionWidgetDropdownAction & { section?: string }, model?: ILanguageModelChatMetadataAndIdentifier, descriptionOverride?: string | MarkdownString, + openerService?: IOpenerService, ): IActionListItem { - const hoverContent = model ? getModelHoverContent(model) : undefined; + const hover = model && openerService ? getModelHoverContent(model, openerService) : undefined; return { item: action, kind: ActionListItemKind.Action, @@ -113,7 +115,7 @@ function createModelItem( group: { title: '', icon: action.icon ?? ThemeIcon.fromId(action.checked ? Codicon.check.id : Codicon.blank.id) }, hideIcon: false, section: action.section, - hover: hoverContent ? { content: hoverContent } : undefined, + hover: hover ? { content: hover.element, disposable: hover.disposable } : undefined, tooltip: action.tooltip, submenuActions: action.toolbarActions?.length ? action.toolbarActions : undefined, }; @@ -252,6 +254,7 @@ export function buildModelPickerItems( showUnavailableFeatured: boolean, showFeatured: boolean, languageModelsService?: ILanguageModelsService, + openerService?: IOpenerService, ): IActionListItem[] { const items: IActionListItem[] = []; if (models.length === 0) { @@ -304,7 +307,7 @@ export function buildModelPickerItems( if (autoModel) { markPlaced(autoModel.identifier, autoModel.metadata.id); const { action: autoAction, descriptionOverride: autoDesc } = createModelAction(autoModel, selectedModelId, onSelect, languageModelsService!); - items.push(createModelItem(autoAction, autoModel, autoDesc)); + items.push(createModelItem(autoAction, autoModel, autoDesc, openerService)); } // --- 2. Promoted section (selected + recently used + featured) --- @@ -393,7 +396,7 @@ export function buildModelPickerItems( for (const item of promotedItems) { if (item.kind === 'available') { const { action: promotedAction, descriptionOverride: promotedDesc } = createModelAction(item.model, selectedModelId, onSelect, languageModelsService!); - items.push(createModelItem(promotedAction, item.model, promotedDesc)); + items.push(createModelItem(promotedAction, item.model, promotedDesc, openerService)); } else { items.push(createUnavailableModelItem(item.id, item.entry, item.reason, manageSettingsUrl, updateStateType, chatEntitlementService)); } @@ -452,7 +455,7 @@ export function buildModelPickerItems( items.push(createUnavailableModelItem(model.metadata.id, entry, 'update', manageSettingsUrl, updateStateType, chatEntitlementService, ModelPickerSection.Other)); } else { const { action: otherAction, descriptionOverride: otherDesc } = createModelAction(model, selectedModelId, onSelect, languageModelsService!, ModelPickerSection.Other); - items.push(createModelItem(otherAction, model, otherDesc)); + items.push(createModelItem(otherAction, model, otherDesc, openerService)); } } } @@ -475,7 +478,7 @@ export function buildModelPickerItems( const autoModel = models.find(m => isAutoModel(m)); if (autoModel) { const { action: flatAutoAction, descriptionOverride: flatAutoDesc } = createModelAction(autoModel, selectedModelId, onSelect, languageModelsService!); - items.push(createModelItem(flatAutoAction, autoModel, flatAutoDesc)); + items.push(createModelItem(flatAutoAction, autoModel, flatAutoDesc, openerService)); } const sortedModels = models .filter(m => m !== autoModel) @@ -485,7 +488,7 @@ export function buildModelPickerItems( }); for (const model of sortedModels) { const { action: flatAction, descriptionOverride: flatDesc } = createModelAction(model, selectedModelId, onSelect, languageModelsService!); - items.push(createModelItem(flatAction, model, flatDesc)); + items.push(createModelItem(flatAction, model, flatDesc, openerService)); } } @@ -777,6 +780,7 @@ export class ModelPickerWidget extends Disposable { this._delegate.showUnavailableFeatured(), this._delegate.showFeatured(), this._languageModelsService, + this._openerService, ); const hasPriceCategories = models.some(m => !!m.metadata.priceCategory); @@ -1082,39 +1086,110 @@ export class ModelPickerWidget extends Disposable { } -function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentifier): MarkdownString | undefined { +function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentifier, openerService: IOpenerService): { element: HTMLElement; disposable: DisposableStore } | undefined { const isAuto = isAutoModel(model); - const markdown = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); - let hasContent = false; + const container = dom.$('.chat-model-hover'); + const disposables = new DisposableStore(); + // --- Model name header --- + container.appendChild(dom.$('.chat-model-hover-name', undefined, model.metadata.name)); + + // --- Description (tooltip as markdown) --- if (model.metadata.tooltip) { + container.appendChild(dom.$('.chat-model-hover-separator')); + const descriptionContainer = dom.$('.chat-model-hover-description'); + const md = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); if (model.metadata.statusIcon) { - markdown.appendMarkdown(`$(${model.metadata.statusIcon.id}) `); + md.appendMarkdown(`$(${model.metadata.statusIcon.id}) `); } - markdown.appendMarkdown(`${model.metadata.tooltip}`); - hasContent = true; + md.appendMarkdown(model.metadata.tooltip); + const rendered = renderMarkdown(md, { + actionHandler: (url: string) => { + openerService.open(URI.parse(url), { allowCommands: true }); + }, + }); + disposables.add(rendered); + descriptionContainer.appendChild(rendered.element); + container.appendChild(descriptionContainer); } - // Show non-multiplier (UBB/AIC) pricing in hover - if (!isAuto && model.metadata.pricing && !isMultiplierPricing(model)) { - if (hasContent) { - markdown.appendMarkdown(`\n\n`); + // --- Cost info --- + if (!isAuto) { + const costLines: { label: string; value: string }[] = []; + if (model.metadata.inputCost !== undefined) { + costLines.push({ + label: localize('models.inputCostLabel', "Input"), + value: model.metadata.inputCost === 1 + ? localize('models.costValueSingular', "{0} credit / 1M tokens", model.metadata.inputCost) + : localize('models.costValuePlural', "{0} credits / 1M tokens", model.metadata.inputCost), + }); + } + if (model.metadata.cacheCost !== undefined) { + costLines.push({ + label: localize('models.cacheCostLabel', "Cached input"), + value: model.metadata.cacheCost === 1 + ? localize('models.costValueSingular', "{0} credit / 1M tokens", model.metadata.cacheCost) + : localize('models.costValuePlural', "{0} credits / 1M tokens", model.metadata.cacheCost), + }); + } + if (model.metadata.outputCost !== undefined) { + costLines.push({ + label: localize('models.outputCostLabel', "Output"), + value: model.metadata.outputCost === 1 + ? localize('models.costValueSingular', "{0} credit / 1M tokens", model.metadata.outputCost) + : localize('models.costValuePlural', "{0} credits / 1M tokens", model.metadata.outputCost), + }); + } + + if (costLines.length > 0) { + const costSection = dom.$('.chat-model-hover-cost'); + costSection.appendChild(dom.$('.chat-model-hover-cost-title', undefined, localize('models.priceTitle', "Price"))); + for (const line of costLines) { + costSection.appendChild(dom.$('.chat-model-hover-cost-line', undefined, + dom.$('span.chat-model-hover-cost-line-label', undefined, `${line.label}: `), + dom.$('span', undefined, line.value), + )); + } + container.appendChild(costSection); + } else if (model.metadata.pricing && !isMultiplierPricing(model)) { + const costSection = dom.$('.chat-model-hover-cost'); + costSection.appendChild(dom.$('span', undefined, localize('models.cost', 'Cost: {0}', model.metadata.pricing))); + container.appendChild(costSection); } - markdown.appendMarkdown(`${localize('models.cost', 'Cost: {0}', model.metadata.pricing)}`); - hasContent = true; } + // --- Context size --- if (!isAuto && (model.metadata.maxInputTokens || model.metadata.maxOutputTokens)) { - if (hasContent) { - markdown.appendMarkdown(`\n\n`); - } const totalTokens = (model.metadata.maxInputTokens ?? 0) + (model.metadata.maxOutputTokens ?? 0); - markdown.appendMarkdown(`${localize('models.contextSize', 'Context Size')}: `); - markdown.appendMarkdown(`${formatTokenCount(totalTokens)}`); - hasContent = true; + const contextSection = dom.$('.chat-model-hover-context'); + contextSection.appendChild(dom.$('.chat-model-hover-context-label', undefined, localize('models.contextSize', "Max context"))); + contextSection.appendChild(dom.$('.chat-model-hover-context-value', undefined, formatTokenCount(totalTokens))); + container.appendChild(contextSection); + } + + // --- Configurable properties --- + if (!isAuto && model.metadata.configurationSchema?.properties) { + const configurableLabels: string[] = []; + for (const [, propSchema] of Object.entries(model.metadata.configurationSchema.properties)) { + if (propSchema.enum && propSchema.enum.length >= 2) { + const label = propSchema.title ?? propSchema.description; + if (label) { + configurableLabels.push(label); + } + } + } + if (configurableLabels.length > 0) { + container.appendChild(dom.$('.chat-model-hover-separator')); + const configRow = dom.$('.chat-model-hover-configurable'); + configRow.appendChild(dom.$('span.chat-model-hover-configurable-label', undefined, localize('models.configurable', "Configurable:"))); + for (const label of configurableLabels) { + configRow.appendChild(dom.$('span.chat-model-hover-configurable-tag', undefined, label)); + } + container.appendChild(configRow); + } } - return hasContent ? markdown : undefined; + return container.children.length > 0 ? { element: container, disposable: disposables } : undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index b0cb727f36e97..e5ead590b230f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -3975,3 +3975,79 @@ have to be updated for changes to the rules above, or to support more deeply nes .monaco-workbench:not(.hc-black):not(.hc-light) .interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row.focused.response { outline: none !important; } + +/* --- Chat Model Hover --- */ +.chat-model-hover { + display: flex; + flex-direction: column; + gap: 8px; +} + +.chat-model-hover-name { + font-weight: bold; + font-size: 13px; +} + +.chat-model-hover-separator { + border-top: 1px solid var(--vscode-menu-separatorBackground, var(--vscode-editorWidget-border)); + margin: 2px 0; +} + +.chat-model-hover-description { + font-size: 12px; + line-height: 1.4; +} + +.chat-model-hover-description > div p { + margin: 0; +} + +.chat-model-hover-cost { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 12px; + color: var(--vscode-descriptionForeground); +} + +.chat-model-hover-cost-title { + font-weight: 600; + margin-bottom: 2px; +} + +.chat-model-hover-cost-line-label { + color: var(--vscode-foreground); + opacity: 0.85; +} + +.chat-model-hover-context { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 12px; + color: var(--vscode-descriptionForeground); +} + +.chat-model-hover-context-label { + font-weight: 600; +} + +.chat-model-hover-configurable { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + font-size: 12px; +} + +.chat-model-hover-configurable-label { + font-weight: 600; +} + +.chat-model-hover-configurable-tag { + padding: 2px 8px; + border-radius: 10px; + border: 1px solid var(--vscode-contrastBorder, var(--vscode-editorWidget-border)); + background: transparent; + font-size: 11px; +} From 2c6eb4778778be035ff7fa05086db13e6fb297e7 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 6 May 2026 17:05:03 -0700 Subject: [PATCH 34/34] Fix agent host config picker order: worktree first, branch second (#314871) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostSessionConfigPicker.ts | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts index 7df5fa3d4091f..c5102a732c165 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts @@ -73,7 +73,7 @@ export interface IConfigPickerItem { } export function getConfigIcon(property: string, value: unknown | undefined): ThemeIcon | undefined { - if (property === 'isolation') { + if (property === SessionConfigKey.Isolation) { if (value === 'folder') { return Codicon.folder; } @@ -81,10 +81,10 @@ export function getConfigIcon(property: string, value: unknown | undefined): The return Codicon.worktree; } } - if (property === 'branch') { + if (property === SessionConfigKey.Branch) { return Codicon.gitBranch; } - if (property === 'autoApprove') { + if (property === SessionConfigKey.AutoApprove) { if (value === 'autopilot') { return Codicon.rocket; } @@ -346,12 +346,25 @@ export class AgentHostSessionConfigPicker extends Disposable { /** * Order the schema properties for rendering. The base implementation - * preserves the schema-declared order; subclasses can override to - * impose a deterministic visual sequence (e.g. the mobile chip row - * groups Approvals | Branch | Worktree). + * enforces a stable visual sequence for well-known properties: + * Isolation (worktree/folder) first, then Branch. Any other properties + * keep their original schema order after these two. Subclasses can + * override to impose a different deterministic visual sequence + * (e.g. the mobile chip row groups Approvals | Branch | Worktree). */ protected _orderProperties(properties: ReadonlyArray<[string, SessionConfigPropertySchema]>): ReadonlyArray<[string, SessionConfigPropertySchema]> { - return properties; + const order = new Map([ + [SessionConfigKey.Isolation, 0], + [SessionConfigKey.Branch, 1], + ]); + return properties + .map(([key, schema], index) => ({ key, schema, index })) + .sort((a, b) => { + const aRank = order.get(a.key) ?? Number.MAX_SAFE_INTEGER; + const bRank = order.get(b.key) ?? Number.MAX_SAFE_INTEGER; + return aRank - bRank || a.index - b.index; + }) + .map(({ key, schema }) => [key, schema] as [string, SessionConfigPropertySchema]); } /**