From 405da76c4079c78d426717da04f3849337885e61 Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Tue, 25 Aug 2026 11:56:57 -0400 Subject: [PATCH 001/116] Fix slight misalignment of compact model picker button (#332554) * Fix slight misalignment of compact model picker button * Remove has * Update blocks-ci screenshot baselines for model picker alignment The compact model picker section now matches the standard chat toolbar item box (border-box, 22px) instead of 16px plus 4px vertical padding, which makes the inline chat zone widget 2px shorter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix stale update-screenshots skill The skill claimed no manual baseline updates were needed and pointed at the removed `.screenshots/baseline/` directory. In reality the `Screenshots & Tests` check fails when a `blocksCi`-labeled fixture's hash changes, and the new hashes must be committed to `test/componentFixtures/blocks-ci-screenshots.md`. Document the actual workflow: where to read the expected hashes, why they must never be regenerated locally, how to verify the visual delta is intentional before accepting it, and the byte-for-byte formatting constraints of the generated file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Stop un-ignoring the generated screenshot baseline directory Screenshot baselines are no longer committed, so the negation no longer protects tracked assets. `.screenshots/baseline/` is now a locally generated session produced by component-explorer-diff.json, so leaving the negation in place exposed generated PNGs as untracked files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/update-screenshots/SKILL.md | 119 ++++++++++++++++-- .gitignore | 1 - .../input/modelPicker/media/modelPicker.css | 15 ++- .../input/modelPicker/modelPickerWidget.ts | 5 +- .../blocks-ci-screenshots.md | 4 +- 5 files changed, 123 insertions(+), 21 deletions(-) diff --git a/.github/skills/update-screenshots/SKILL.md b/.github/skills/update-screenshots/SKILL.md index 4b160ad9be2..e1b6361de36 100644 --- a/.github/skills/update-screenshots/SKILL.md +++ b/.github/skills/update-screenshots/SKILL.md @@ -1,27 +1,120 @@ --- name: update-screenshots -description: Download screenshot baselines from the latest CI run and commit them. Use when asked to update, accept, or refresh component screenshot baselines from CI, or after the screenshot-test GitHub Action reports differences. This skill should be run as a subagent. +description: Update the committed blocks-ci screenshot hashes after the "Screenshots & Tests" check fails, or investigate a screenshot diff reported on a PR. Use when asked to update, accept, or refresh component screenshot baselines from CI. This skill should be run as a subagent. --- # Update Component Screenshots from CI -Screenshot baselines are **no longer stored in the repository**. They are managed by an external screenshot service (`hediet-screenshots.azurewebsites.net`). The CI workflow uploads screenshots to this service and diffs them automatically. +Screenshot **images** are not stored in the repository — they live in an external service +(`hediet-screenshots.azurewebsites.net`), keyed by commit SHA. But a subset of fixtures is +pinned by **hash** in [`test/componentFixtures/blocks-ci-screenshots.md`](../../../test/componentFixtures/blocks-ci-screenshots.md), +and that file **is** committed. When those hashes change, CI fails and you must update the file. -When the `Checking Component Screenshots` GitHub Action detects changes, it posts a PR comment with before/after comparisons. No manual baseline updates are needed — the screenshots on the `main` branch commit become the new baselines automatically after merge. +## Two different outcomes, only one of which blocks -## What Changed +The `Screenshots & Tests` job in [`.github/workflows/component-fixtures.yml`](../../workflows/component-fixtures.yml) +produces two independent results: -- Baseline images were removed from `test/componentFixtures/.screenshots/baseline/`. -- Git LFS is no longer used for screenshot storage. -- The screenshot service stores images keyed by commit SHA and handles diffing. +| Result | Blocking? | Action | +| --- | --- | --- | +| Screenshot **diff report** (PR comment with before/after images) | No — informational | Review the visuals. Nothing to commit. | +| **blocks-ci hash mismatch** | **Yes — fails the check** | Update `blocks-ci-screenshots.md` and commit. | -## If Screenshots Need Investigation +A fixture opts into the blocking gate with `labels: { kind: 'screenshot', blocksCi: true }`. +Only those fixtures appear in `blocks-ci-screenshots.md`. -1. Check the PR comment posted by the CI workflow for visual diffs. -2. Download the `screenshots` artifact from the CI run for the raw captured images: +The failure looks like this: -```bash -gh run download --name screenshots --dir .tmp/screenshots +``` +##[error]blocks-ci screenshot hashes do not match committed file. See PR comment or job summary for the updated content. ``` -3. Compare locally if needed. The artifact contains the full set of captured screenshots. +## Step 1: Get the expected hashes from CI + +> **Never regenerate the hashes locally.** They are hashes of the rendered PNG bytes, produced +> on `ubuntu-latest`. Rendering on macOS or Windows yields different bytes and therefore +> different hashes, so locally generated values will fail CI. Always copy the values from the +> CI job. + +Three surfaces carry the same content — use whichever is handy: + +- The **PR comment** titled "blocks-ci screenshots changed" (non-fork PRs only) — contains the + full updated file plus a patch. +- The **job summary**, which gets the identical body and is the only surface fork PRs receive. +- The **job log**, whose final step prints a unified diff: + +```bash +gh api repos/microsoft/vscode/actions/jobs//logs > "$TMPDIR/ci-job-log.txt" +grep -n '##\[error\]' "$TMPDIR/ci-job-log.txt" +``` + +Find the failed job id with: + +```bash +gh pr checks --json name,link,bucket --jq '.[] | select(.name == "Screenshots & Tests")' +``` + +## Step 2: Verify the change is intentional before accepting it + +This gate exists to catch **unintended** layout regressions, so accepting new hashes without +looking at the images defeats its purpose. The images are publicly fetchable by hash, so pull +both the old (committed) and new (from CI) versions and compare: + +```bash +curl -sL -o old.png "https://hediet-screenshots.azurewebsites.net/images/" +curl -sL -o new.png "https://hediet-screenshots.azurewebsites.net/images/" +``` + +Then view them, and localize the change rather than eyeballing full screenshots — the delta is +often only a pixel or two: + +```bash +python3 -c " +from PIL import Image, ImageChops +a = Image.open('old.png').convert('RGB'); b = Image.open('new.png').convert('RGB') +print('diff bbox:', ImageChops.difference(a, b).getbbox()) +" +``` + +Confirm the delta matches what the PR intends. If the fixture is unrelated to the change, or +the shift is larger than expected, treat it as a regression and fix the code instead of the +hashes. + +## Step 3: Apply and commit + +Edit only the changed lines in `test/componentFixtures/blocks-ci-screenshots.md`, replacing the +old hash in the image URL with the new one: + +```md +#### editor/inlineChatZoneWidget/InlineChatZoneWidget/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/) +``` + +The file is generated by [`build/lib/screenshotBlocksCi.ts`](../../../build/lib/screenshotBlocksCi.ts) +and compared **byte-for-byte**, so keep the `` +header, the `#### ` / image-link pairing, the blank line between entries, and the +`fixtureId` sort order intact. Verify your edit is the exact inverse of the diff CI reported: + +```bash +git diff test/componentFixtures/blocks-ci-screenshots.md +``` + +Then commit and push. The check re-runs and should pass; hashes on `main` become the new +baseline after merge. + +## Investigating further + +Raw captured images and the manifest for a run are uploaded as an artifact: + +```bash +gh run download --name screenshots --dir .tmp/screenshots +``` + +`manifest.json` maps each `fixtureId` to its `imageHash` and any render errors. + +## Related failures from the same job + +The check also fails if a fixture **failed to render** (`Fail if fixtures had errors`) or if the +Playwright fixture tests failed. Those are genuine bugs — updating hashes will not help. Look +for `::error:::` in the log, and download the `playwright-test-results` artifact for +test failures. diff --git a/.gitignore b/.gitignore index aa2eef92792..76c3faaa8cc 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,6 @@ product.overrides.json vscode-telemetry-docs/ test-output.json test/componentFixtures/.screenshots/* -!test/componentFixtures/.screenshots/baseline/ dist .playwright-cli .playwright-mcp diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css index 3694daa745f..0a7f2fbcb81 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css @@ -11,7 +11,6 @@ padding: 0; overflow: visible; height: auto; - border-radius: 0; position: relative; cursor: default; } @@ -19,15 +18,23 @@ .chat-input-picker-item .action-label.model-picker-split .model-picker-section { display: flex; align-items: center; - height: 16px; - padding: var(--vscode-spacing-size40) var(--vscode-spacing-size60); - border-radius: 4px; + box-sizing: border-box; + height: 22px; + padding: 0 var(--vscode-spacing-size60); + border-radius: inherit; cursor: pointer; text-decoration: none; color: inherit; white-space: nowrap; } +.interactive-session .chat-input-toolbar .chat-input-picker-item .action-label.model-picker-split.icon-only .model-picker-section { + width: 100%; + height: 100%; + padding: 0; + justify-content: center; +} + .chat-input-picker-item .action-label.model-picker-split:hover, .chat-input-picker-item .action-label.model-picker-split[aria-expanded="true"] { background-color: transparent !important; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts index 9c12fb6f590..19ce341dfce 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts @@ -606,7 +606,8 @@ export class ModelPickerWidget extends Disposable { : genericNoModels ? localize('chat.modelPicker.noModels', "No models available") : (name ?? localize('chat.modelPicker.auto', "Auto")); - if (!compact || !modelIcon || noModelsAvailable) { + const showModelLabel = !compact || !modelIcon || noModelsAvailable; + if (showModelLabel) { nameChildren.push(dom.$('span.chat-input-picker-label', undefined, modelLabel)); } if (this._badgeIcon) { @@ -614,6 +615,8 @@ export class ModelPickerWidget extends Disposable { } dom.reset(this._nameButton, ...nameChildren); + this._domNode.classList.toggle('icon-only', !showModelLabel); + if (this._configButton) { this._configuration.renderButton(this._configButton, compact, noModelsAvailable); } diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index daa57c5c2e2..bf64b86f8cf 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -79,10 +79,10 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/7f70224f7733a2461eba63fa98234aab38b8804a73460deffa11f49cd6f7172c) #### editor/inlineChatZoneWidget/InlineChatZoneWidget/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/7700bb9cad18d064af94493b4ae0a4f75e3c855df7ba4eb1d8a4a562eaa41dc6) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/f9e5bfb616a989cd170f3aafd172838918c8f40fff0cbbcd5c595cc2405de2dc) #### editor/inlineChatZoneWidget/InlineChatZoneWidget/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/7f3cd7b0e664da973a1bb4c80f5d22005261f2eee798ffee0d3d95b48bf431b3) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/385d01a93004536ce28abfc8d99812dc43e08db035983a72d3d762a6754f29ce) #### editor/inlineChatZoneWidget/InlineChatZoneWidgetTerminated/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/0752cf02ae3a4e21fce84b62859df32a5f41c13622bdec0083a3fd46832c2e0a) From c659a2ccd33e71b02f002b453e84ef8de2f1aa2d Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Tue, 25 Aug 2026 09:13:35 -0700 Subject: [PATCH 002/116] Fix terminal tool progress listener leak (#332460) * Fix terminal tool progress listener leak Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add rendered terminal listener regression coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use real terminal capabilities in listener test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chatTerminalToolProgressPart.ts | 18 +- .../chatTerminalToolProgressPart.test.ts | 198 +++++++++++++++++- .../contrib/terminal/browser/terminal.ts | 2 + .../chat/browser/terminalChatService.ts | 5 + .../test/browser/terminalChatService.test.ts | 31 ++- 5 files changed, 243 insertions(+), 11 deletions(-) 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 7d49548bc5c..25310ee3d4c 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 @@ -330,6 +330,10 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart return this._contentIndex; } + public get terminalToolSessionId(): string | undefined { + return this._terminalData.terminalToolSessionId; + } + constructor( toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, terminalData: IChatTerminalToolInvocationData | ILegacyChatTerminalToolInvocationData, @@ -438,7 +442,6 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart initializeTerminalActionsOnce(); }); - // Listen for continue in background — updates toolbar to auto-hide the action const terminalToolSessionId = this._terminalData.terminalToolSessionId; if (terminalToolSessionId) { if (this._terminalData.isPty === false) { @@ -449,13 +452,6 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart } })); } - this._register(this._terminalChatService.onDidContinueInBackground(sessionId => { - if (sessionId === terminalToolSessionId) { - this._terminalData.didContinueInBackground = true; - this._toolbarCanContinueInBackground = false; - this._updateToolbarActions(); - } - })); } let pastTenseMessage: string | undefined; if (toolInvocation.pastTenseMessage) { @@ -1217,6 +1213,12 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart } } + public markContinuedInBackground(): void { + this._terminalData.didContinueInBackground = true; + this._toolbarCanContinueInBackground = false; + this._updateToolbarActions(); + } + public async toggleOutputFromAction(): Promise { this._userToggledOutput = true; diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTerminalToolProgressPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTerminalToolProgressPart.test.ts index 03fedbf67d1..bee11808a7e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTerminalToolProgressPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTerminalToolProgressPart.test.ts @@ -6,26 +6,220 @@ import assert from 'assert'; import type { Terminal } from '@xterm/xterm'; import { importAMDNodeModule } from '../../../../../../../amdX.js'; +import { renderAsPlaintext } from '../../../../../../../base/browser/markdownRenderer.js'; import { mainWindow } from '../../../../../../../base/browser/window.js'; import { Emitter, Event } from '../../../../../../../base/common/event.js'; import { observableValue } from '../../../../../../../base/common/observable.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { toDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { mock } from '../../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { runWithFakedTimers } from '../../../../../../../base/test/common/timeTravelScheduler.js'; import { timeout } from '../../../../../../../base/common/async.js'; import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IAccessibleViewService } from '../../../../../../../platform/accessibility/browser/accessibleView.js'; +import { IMarkdownRenderer } from '../../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { TerminalCapabilityStore } from '../../../../../../../platform/terminal/common/capabilities/terminalCapabilityStore.js'; import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; +import { IAiEditTelemetryService } from '../../../../../editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.js'; +import { IChatOutputRendererService } from '../../../../browser/chatOutputItemRenderer.js'; +import { IChatMarkdownAnchorService } from '../../../../browser/widget/chatContentParts/chatMarkdownAnchorService.js'; import { IChatContentPartRenderContext, InlineTextModelCollection } from '../../../../browser/widget/chatContentParts/chatContentParts.js'; import { DiffEditorPool, EditorPool } from '../../../../browser/widget/chatContentParts/chatContentCodePools.js'; -import { ChatTerminalThinkingCollapsibleWrapper, ChatTerminalToolOutputSection } from '../../../../browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.js'; +import { ChatTerminalThinkingCollapsibleWrapper, ChatTerminalToolOutputSection, ChatTerminalToolProgressPart } from '../../../../browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.js'; +import { IChatSessionsService } from '../../../../common/chatSessionsService.js'; +import { IChatTerminalToolInvocationData, IChatToolInvocationSerialized, ToolConfirmKind } from '../../../../common/chatService/chatService.js'; import { IChatResponseViewModel } from '../../../../common/model/chatViewModel.js'; import { TerminalToolAutoExpand, TerminalToolAutoExpandTimeout } from '../../../../browser/widget/chatContentParts/toolInvocationParts/terminalToolAutoExpand.js'; -import { ITerminalConfigurationService, ITerminalService, type IDetachedXTermOptions } from '../../../../../terminal/browser/terminal.js'; +import { IChatTerminalToolProgressPart, ITerminalChatService, ITerminalConfigurationService, ITerminalInstance, ITerminalService, type IDetachedXTermOptions } from '../../../../../terminal/browser/terminal.js'; import type { ITerminalFont } from '../../../../../terminal/common/terminal.js'; import { createFakeDetachedTerminal } from '../../../../../terminal/test/browser/chatTerminalMirrorTestUtils.js'; +function listenerCount(emitter: Emitter): number { + return (emitter as unknown as { _size: number })._size ?? 0; +} + +class TestTerminalChatService extends mock() { + override readonly onDidRegisterTerminalInstanceWithToolSession = Event.None; + override readonly onDidRegisterOutputSource = Event.None; + override readonly onDidContinueInBackground: Event; + + private readonly progressParts = new Set(); + + constructor( + private readonly continueInBackgroundEmitter: Emitter, + private readonly terminalInstance: ITerminalInstance, + ) { + super(); + this.onDidContinueInBackground = continueInBackgroundEmitter.event; + } + + override async getTerminalInstanceByToolSessionId(_terminalToolSessionId: string): Promise { + return this.terminalInstance; + } + + override registerProgressPart(part: IChatTerminalToolProgressPart) { + this.progressParts.add(part); + return toDisposable(() => this.progressParts.delete(part)); + } + + override continueInBackground(terminalToolSessionId: string): void { + this.continueInBackgroundEmitter.fire(terminalToolSessionId); + for (const part of this.progressParts) { + if (part.terminalToolSessionId === terminalToolSessionId) { + part.markContinuedInBackground(); + } + } + } + + override isBackgroundTerminal(): boolean { + return false; + } + + override getOutputSource() { + return undefined; + } + + override getAhpCommandSource() { + return undefined; + } + + override setFocusedProgressPart(): void { } + override clearFocusedProgressPart(): void { } +} + +suite('ChatTerminalToolProgressPart listener ownership', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('rendered parts do not accumulate continue listeners and duplicate rows update', async () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const continueInBackgroundEmitter = store.add(new Emitter()); + const capabilities = store.add(new TerminalCapabilityStore()); + const terminalInstance = new class extends mock() { + override readonly isDisposed = false; + override readonly onDisposed = Event.None; + override readonly onWillData = Event.None; + override readonly capabilities = capabilities; + }(); + const terminalChatService = new TestTerminalChatService(continueInBackgroundEmitter, terminalInstance); + instantiationService.stub(ITerminalChatService, terminalChatService); + instantiationService.stub(ITerminalService, new class extends mock() { + override readonly whenConnected = Promise.resolve(); + }()); + instantiationService.stub(IAccessibleViewService, new class extends mock() { }()); + instantiationService.stub(IChatMarkdownAnchorService, { + _serviceBrand: undefined, + register: () => toDisposable(() => { }), + lastFocusedAnchor: undefined, + }); + instantiationService.stub(IAiEditTelemetryService, new class extends mock() { }()); + instantiationService.stub(IChatOutputRendererService, new class extends mock() { + override hasCodeBlockRenderer(): boolean { + return false; + } + }()); + instantiationService.stub(IChatSessionsService, new class extends mock() { }()); + + const markdownRenderer: IMarkdownRenderer = { + render: (markdown, _options, outElement) => { + const element = outElement ?? mainWindow.document.createElement('div'); + element.textContent = renderAsPlaintext(markdown); + return { element, dispose() { } }; + } + }; + const editorPool = Object.create(EditorPool.prototype) as EditorPool; + const host = mainWindow.document.createElement('div'); + mainWindow.document.body.appendChild(host); + store.add(toDisposable(() => host.remove())); + const eventSessionIds: string[] = []; + store.add(continueInBackgroundEmitter.event(sessionId => eventSessionIds.push(sessionId))); + const listenerCountBeforeRender = listenerCount(continueInBackgroundEmitter); + + const targetSessionId = 'terminal-session-target'; + const terminalData: IChatTerminalToolInvocationData[] = []; + const parts: ChatTerminalToolProgressPart[] = []; + for (let index = 0; index < 50; index++) { + const data: IChatTerminalToolInvocationData = { + kind: 'terminal', + commandLine: { original: `echo ${index}` }, + language: 'shellscript', + terminalToolSessionId: index === 24 || index === 25 ? targetSessionId : `terminal-session-${index}`, + }; + const invocation: IChatToolInvocationSerialized = { + presentation: undefined, + toolSpecificData: data, + invocationMessage: 'Running command', + originMessage: undefined, + pastTenseMessage: 'Ran command', + isConfirmed: { type: ToolConfirmKind.ConfirmationNotNeeded }, + isComplete: true, + toolCallId: `tool-call-${index}`, + toolId: 'run_in_terminal', + source: undefined, + kind: 'toolInvocationSerialized', + }; + const element = Object.assign(Object.create(null) as IChatResponseViewModel, { + id: `response-${index}`, + isComplete: true, + sessionResource: URI.parse('chat-session://test/session'), + setVote() { }, + get model() { return {} as IChatResponseViewModel['model']; }, + }); + const context: IChatContentPartRenderContext = { + element, + elementIndex: index, + container: host, + content: [invocation], + contentIndex: 0, + inlineTextModels: Object.create(InlineTextModelCollection.prototype) as InlineTextModelCollection, + editorPool, + codeBlockStartIndex: 0, + treeStartIndex: 0, + diffEditorPool: Object.create(DiffEditorPool.prototype) as DiffEditorPool, + currentWidth: observableValue('testWidth', 500), + onDidChangeVisibility: Event.None, + }; + const part = store.add(instantiationService.createInstance( + ChatTerminalToolProgressPart, + invocation, + data, + context, + markdownRenderer, + editorPool, + () => 500, + 0, + )); + host.appendChild(part.domNode); + terminalData.push(data); + parts.push(part); + } + await timeout(0); + + const listenerCountAfterRender = listenerCount(continueInBackgroundEmitter); + const actionCountsBefore = parts.map(part => part.domNode.querySelectorAll('.action-item').length); + parts[24].continueInBackground(); + const actionCountsAfter = parts.map(part => part.domNode.querySelectorAll('.action-item').length); + + assert.deepStrictEqual({ + renderedRows: parts.filter(part => part.domNode.isConnected).length, + listenerCounts: [listenerCountBeforeRender, listenerCountAfterRender], + actionCountsBefore: [...new Set(actionCountsBefore)], + continuedRows: terminalData.flatMap((data, index) => data.didContinueInBackground ? [index] : []), + matchingActionCountsAfter: [actionCountsAfter[24], actionCountsAfter[25]], + unmatchedActionCountAfter: actionCountsAfter[0], + eventSessionIds, + }, { + renderedRows: 50, + listenerCounts: [1, 1], + actionCountsBefore: [2], + continuedRows: [24, 25], + matchingActionCountsAfter: [1, 1], + unmatchedActionCountAfter: 2, + eventSessionIds: [targetSessionId], + }); + }); +}); + suite('ChatTerminalToolProgressPart Auto-Expand Logic', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index 593e875a60d..0dcfa551ee8 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -123,10 +123,12 @@ export interface IAhpTerminalCommandSource extends IDisposable { export interface IChatTerminalToolProgressPart { readonly elementIndex: number; readonly contentIndex: number; + readonly terminalToolSessionId: string | undefined; focusTerminal(): Promise; toggleOutputFromKeyboard(): Promise; toggleOutputFromAction(): Promise; continueInBackground(): void; + markContinuedInBackground(): void; focusOutput(): void; getCommandAndOutputAsText(): string | undefined; } diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts index 584d47815e7..33c5d43295d 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts @@ -467,6 +467,11 @@ export class TerminalChatService extends Disposable implements ITerminalChatServ continueInBackground(terminalToolSessionId: string): void { this._onDidContinueInBackground.fire(terminalToolSessionId); + for (const part of this._activeProgressParts) { + if (part.terminalToolSessionId === terminalToolSessionId) { + part.markContinuedInBackground(); + } + } } registerAhpCommandSource(terminalToolSessionId: string, source: IAhpTerminalCommandSource, promisedTerminal: Promise): IDisposable { diff --git a/src/vs/workbench/contrib/terminalContrib/chat/test/browser/terminalChatService.test.ts b/src/vs/workbench/contrib/terminalContrib/chat/test/browser/terminalChatService.test.ts index 7068955a9e6..5ed938b4586 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/test/browser/terminalChatService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/test/browser/terminalChatService.test.ts @@ -17,7 +17,7 @@ import { ILogService, NullLogService } from '../../../../../../platform/log/comm import { ITreeSitterLibraryService } from '../../../../../../editor/common/services/treeSitter/treeSitterLibraryService.js'; import { InMemoryStorageService, IStorageService } from '../../../../../../platform/storage/common/storage.js'; import { IChatService } from '../../../../chat/common/chatService/chatService.js'; -import { IAhpTerminalCommandSource, ITerminalInstance, ITerminalService } from '../../../../terminal/browser/terminal.js'; +import { IAhpTerminalCommandSource, IChatTerminalToolProgressPart, ITerminalInstance, ITerminalService } from '../../../../terminal/browser/terminal.js'; import { TerminalChatService } from '../../browser/terminalChatService.js'; /** @@ -106,6 +106,35 @@ suite('TerminalChatService', () => { assert.strictEqual(service.getToolSessionIdForInstance(instance), 'tool-session-a'); }); + test('continueInBackground notifies every matching progress part', () => { + const markedPartIndices: number[] = []; + const targetSessionId = 'tool-session-target'; + for (let index = 0; index < 50; index++) { + const sessionId = index === 25 || index === 26 ? targetSessionId : `tool-session-${index}`; + store.add(service.registerProgressPart(new class extends mock() { + override readonly elementIndex = index; + override readonly contentIndex = 0; + override readonly terminalToolSessionId = sessionId; + + override markContinuedInBackground(): void { + markedPartIndices.push(index); + } + }())); + } + const eventSessionIds: string[] = []; + store.add(service.onDidContinueInBackground(sessionId => eventSessionIds.push(sessionId))); + + service.continueInBackground(targetSessionId); + + assert.deepStrictEqual({ + markedPartIndices, + eventSessionIds, + }, { + markedPartIndices: [25, 26], + eventSessionIds: [targetSessionId], + }); + }); + test('getTerminalInstanceByToolSessionId waits for pending AHP terminal creation', async () => { const pendingTerminal = new DeferredPromise(); const instance = { instanceId: 3 } as ITerminalInstance; From e79a9aaed8655f40a4061023114cc894c60ba44a Mon Sep 17 00:00:00 2001 From: Robo Date: Wed, 26 Aug 2026 01:32:23 +0900 Subject: [PATCH 003/116] ci: fix electron types in product builds (#332539) * ci: fix electron types in product builds * temp: bump distro * chore: cleanup * chore: bump distro --- build/azure-pipelines/alpine/product-build-alpine.yml | 3 +++ build/azure-pipelines/common/computeNodeModulesCacheKey.ts | 1 - build/azure-pipelines/common/listNodeModules.ts | 3 --- .../darwin/steps/product-build-darwin-compile.yml | 3 +++ .../linux/steps/product-build-linux-compile.yml | 3 +++ build/azure-pipelines/product-quality-checks.yml | 3 +++ build/azure-pipelines/web/product-build-web.yml | 3 +++ build/azure-pipelines/win32/sdl-scan-win32.yml | 3 +++ .../win32/steps/product-build-win32-compile.yml | 3 +++ package.json | 2 +- 10 files changed, 22 insertions(+), 5 deletions(-) diff --git a/build/azure-pipelines/alpine/product-build-alpine.yml b/build/azure-pipelines/alpine/product-build-alpine.yml index 435fb9adf8e..6c72b85a219 100644 --- a/build/azure-pipelines/alpine/product-build-alpine.yml +++ b/build/azure-pipelines/alpine/product-build-alpine.yml @@ -201,6 +201,9 @@ jobs: - template: ../common/install-builtin-extensions.yml@self + - script: node build/npm/electronTypes.ts + displayName: Prepare Electron types + - template: ../common/agent-sdk-produce.yml@self parameters: vscodePlatform: alpine diff --git a/build/azure-pipelines/common/computeNodeModulesCacheKey.ts b/build/azure-pipelines/common/computeNodeModulesCacheKey.ts index c868a15d080..e5dbc06aa94 100644 --- a/build/azure-pipelines/common/computeNodeModulesCacheKey.ts +++ b/build/azure-pipelines/common/computeNodeModulesCacheKey.ts @@ -15,7 +15,6 @@ shasum.update(fs.readFileSync(path.join(ROOT, 'build/.cachesalt'))); shasum.update(fs.readFileSync(path.join(ROOT, '.npmrc'))); shasum.update(fs.readFileSync(path.join(ROOT, 'build', '.npmrc'))); shasum.update(fs.readFileSync(path.join(ROOT, 'remote', '.npmrc'))); -shasum.update(fs.readFileSync(path.join(import.meta.dirname, 'listNodeModules.ts'))); // Add `package.json` and `package-lock.json` files for (const dir of dirs) { diff --git a/build/azure-pipelines/common/listNodeModules.ts b/build/azure-pipelines/common/listNodeModules.ts index fd894d1d68f..5ab955faca4 100644 --- a/build/azure-pipelines/common/listNodeModules.ts +++ b/build/azure-pipelines/common/listNodeModules.ts @@ -5,7 +5,6 @@ import fs from 'fs'; import path from 'path'; -import { ensureElectronTypes } from '../../npm/electronTypes.ts'; if (process.argv.length !== 3) { console.error('Usage: node listNodeModules.ts OUTPUT_FILE'); @@ -41,7 +40,5 @@ function findNodeModulesFiles(location: string, inNodeModules: boolean, result: } const result: string[] = []; -await ensureElectronTypes(); findNodeModulesFiles('', false, result); -result.push('.build/typings/electron.d.ts'); fs.writeFileSync(process.argv[2], result.join('\n') + '\n'); diff --git a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml index 15630252929..7033d64c843 100644 --- a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml +++ b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml @@ -158,6 +158,9 @@ steps: - template: ../../common/install-builtin-extensions.yml@self + - script: node build/npm/electronTypes.ts + displayName: Prepare Electron types + - template: ../../common/agent-sdk-produce.yml@self parameters: vscodePlatform: darwin diff --git a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml index e5b93a11fad..d061b7c0c4e 100644 --- a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml +++ b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml @@ -205,6 +205,9 @@ steps: - template: ../../common/install-builtin-extensions.yml@self + - script: node build/npm/electronTypes.ts + displayName: Prepare Electron types + - ${{ if ne(parameters.VSCODE_ARCH, 'armhf') }}: - template: ../../common/agent-sdk-produce.yml@self parameters: diff --git a/build/azure-pipelines/product-quality-checks.yml b/build/azure-pipelines/product-quality-checks.yml index 2282b69ef03..59df292c8fb 100644 --- a/build/azure-pipelines/product-quality-checks.yml +++ b/build/azure-pipelines/product-quality-checks.yml @@ -131,6 +131,9 @@ jobs: - script: node build/azure-pipelines/distro/mixin-quality.ts displayName: Mixin distro quality + - script: node build/npm/electronTypes.ts + displayName: Prepare Electron types + - script: node build/azure-pipelines/common/checkDistroCommit.ts displayName: Check distro commit env: diff --git a/build/azure-pipelines/web/product-build-web.yml b/build/azure-pipelines/web/product-build-web.yml index 131f2502b1d..6a6c132da17 100644 --- a/build/azure-pipelines/web/product-build-web.yml +++ b/build/azure-pipelines/web/product-build-web.yml @@ -121,6 +121,9 @@ jobs: - template: ../common/install-builtin-extensions.yml@self + - script: node build/npm/electronTypes.ts + displayName: Prepare Electron types + - script: npx deemon --detach --wait -- node build/azure-pipelines/common/downloadCopilotVsix.ts env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) diff --git a/build/azure-pipelines/win32/sdl-scan-win32.yml b/build/azure-pipelines/win32/sdl-scan-win32.yml index 9d49b70a9a7..01d29af19c0 100644 --- a/build/azure-pipelines/win32/sdl-scan-win32.yml +++ b/build/azure-pipelines/win32/sdl-scan-win32.yml @@ -110,6 +110,9 @@ steps: - template: ../common/install-builtin-extensions.yml@self + - script: node build/npm/electronTypes.ts + displayName: Prepare Electron types + - template: ../common/mixin-vscode-capi.yml@self - powershell: npm run gulp core-ci diff --git a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml index 721c94a8dac..b5eee1b438f 100644 --- a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml +++ b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml @@ -147,6 +147,9 @@ steps: - template: ../../common/install-builtin-extensions.yml@self + - powershell: node build/npm/electronTypes.ts + displayName: Prepare Electron types + - template: ../../common/agent-sdk-produce.yml@self parameters: vscodePlatform: win32 diff --git a/package.json b/package.json index d42b089438e..ec31ea544f4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.136.0", - "distro": "406e12a29f51487d7c905069d25636a6e641097a", + "distro": "17390aec44f690a102a9016a010c1089af3d198b", "author": { "name": "Microsoft Corporation" }, From f173dada5229da9fadda70e5c63b736ea0d81bb6 Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Tue, 25 Aug 2026 12:35:54 -0400 Subject: [PATCH 004/116] Fix lone json surrogate breaking requests (#332564) * Fix lone json surrogate breaking requests * Sanitize lone surrogates in JSON property names too Repair the serialized JSON instead of using a `JSON.stringify` replacer, which can only rewrite values, and reject top level values that have no JSON representation instead of tripping over an `undefined` result. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Avoid quadratic backtracking when sanitizing surrogates Matching `\\+u...` backtracks quadratically over a run of backslashes, which is reachable from tool output: 32k backslashes took two seconds. Match every escape instead so escaped backslashes are consumed whole, which keeps parity handling correct without rescanning, and restore the cheap pre-check that skips the scan for bodies with no escape at all. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Ignore a symlinked node_modules A trailing slash restricts a pattern to directories, and git treats a symlink as a blob rather than a directory, so `node_modules/` let a symlinked node_modules show up as untracked and be swept into a commit by `git add -A`. Drop the slash, matching `build/node_modules` on the line below and the five extension .gitignore files that already do. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 3 +- .../platform/networking/common/jsonBody.ts | 49 ++++++++++++ .../networking/node/baseFetchFetcher.ts | 3 +- .../networking/node/chatWebSocketManager.ts | 3 +- .../platform/networking/node/nodeFetcher.ts | 3 +- .../networking/test/node/jsonBody.spec.ts | 80 +++++++++++++++++++ 6 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 extensions/copilot/src/platform/networking/common/jsonBody.ts create mode 100644 extensions/copilot/src/platform/networking/test/node/jsonBody.spec.ts diff --git a/.gitignore b/.gitignore index 76c3faaa8cc..17a28b4011e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,8 @@ .cache npm-debug.log Thumbs.db -node_modules/ +# No trailing slash, so a symlinked node_modules is ignored too +node_modules .tmp/ .build/ /extensionsCG/ diff --git a/extensions/copilot/src/platform/networking/common/jsonBody.ts b/extensions/copilot/src/platform/networking/common/jsonBody.ts new file mode 100644 index 00000000000..4ae0434d7c8 --- /dev/null +++ b/extensions/copilot/src/platform/networking/common/jsonBody.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Matches a single escape sequence in serialized JSON, capturing the part after the backslash. + * + * Matching *every* escape rather than only the surrogate ones is what keeps this both correct and + * linear. An escaped backslash is consumed whole, so text that merely looks like an escape (a + * literal `\ud83d`, which `JSON.stringify` writes as `\\ud83d`) can never be mistaken for one, and + * no position is ever rescanned. Matching `\\+u...` instead would be quadratic in the length of a + * run of backslashes, which is reachable from tool output. + */ +const JSON_ESCAPE = /\\(u[dD][89a-fA-F][0-9a-fA-F]{2}|[\s\S])/g; + +/** The escaped form of the Unicode replacement character, `\uFFFD`. */ +const UNICODE_REPLACEMENT_ESCAPE = '\\ufffd'; + +/** + * Serializes a request body to JSON that can be decoded as UTF-8 by the receiving service. + * + * `JSON.stringify` turns an unpaired surrogate into a `\uXXXX` escape rather than failing. That + * escape has no UTF-8 encoding, so strict server-side JSON parsers reject the whole body with a + * `400`. Because a rejected body is usually replayed conversation history, a single bad code unit + * keeps failing every later request in that session until the conversation is abandoned. + * + * The repair happens on the serialized JSON rather than on the input because a `JSON.stringify` + * replacer cannot rewrite property names, only values. + * + * @throws if `value` has no JSON representation at all, such as `undefined` or a function. + */ +export function stringifyJsonBody(value: unknown): string { + const serialized = JSON.stringify(value); + if (typeof serialized !== 'string') { + throw new Error(`Illegal arguments! A value of type '${typeof value}' has no JSON representation!`); + } + // `JSON.stringify` escapes a surrogate code unit only when it is unpaired, and always writes the + // escape in lowercase; well-formed pairs and every other non-ASCII character are written as + // literal characters. Bodies are usually replayed conversation history and almost never contain + // such an escape, so skip the scan entirely rather than walking megabytes for nothing. Text that + // itself contains a `\ud` sequence merely costs a redundant scan, so this check is allowed to be + // over-eager but never under-eager. + if (!serialized.includes('\\ud')) { + return serialized; + } + return serialized.replace(JSON_ESCAPE, (match, escape: string) => + escape.length === 1 ? match : UNICODE_REPLACEMENT_ESCAPE); +} diff --git a/extensions/copilot/src/platform/networking/node/baseFetchFetcher.ts b/extensions/copilot/src/platform/networking/node/baseFetchFetcher.ts index b7621ae704f..4af9082a4ca 100644 --- a/extensions/copilot/src/platform/networking/node/baseFetchFetcher.ts +++ b/extensions/copilot/src/platform/networking/node/baseFetchFetcher.ts @@ -8,6 +8,7 @@ import { generateUuid } from '../../../util/vs/base/common/uuid'; import { IEnvService } from '../../env/common/envService'; import { collectSingleLineErrorMessage } from '../../log/common/logService'; import { CacheStatus, FetcherId, FetchOptions, IAbortController, isAbortError, PaginationOptions, ReportFetchEvent, Response, safeGetHostname } from '../common/fetcherService'; +import { stringifyJsonBody } from '../common/jsonBody'; import { IFetcher, userAgentLibraryHeader } from '../common/networking'; import { VSCODE_CACHE_STATUS_HEADER } from './taggedCacheInterceptor'; @@ -43,7 +44,7 @@ export abstract class BaseFetchFetcher implements IFetcher { throw new Error(`Illegal arguments! Cannot pass in both 'body' and 'json'!`); } headers['Content-Type'] = 'application/json'; - body = JSON.stringify(options.json); + body = stringifyJsonBody(options.json); } const method = options.method || 'GET'; diff --git a/extensions/copilot/src/platform/networking/node/chatWebSocketManager.ts b/extensions/copilot/src/platform/networking/node/chatWebSocketManager.ts index 133f62e3c82..a4cecd34959 100644 --- a/extensions/copilot/src/platform/networking/node/chatWebSocketManager.ts +++ b/extensions/copilot/src/platform/networking/node/chatWebSocketManager.ts @@ -16,6 +16,7 @@ import { ICAPIClientService } from '../../endpoint/common/capiClient'; import { ILogService, collectSingleLineErrorMessage } from '../../log/common/logService'; import { ITelemetryService } from '../../telemetry/common/telemetry'; import { HeadersImpl, IHeaders, WebSocketConnection } from '../common/fetcherService'; +import { stringifyJsonBody } from '../common/jsonBody'; import { IEndpointBody } from '../common/networking'; import { getResponsesApiCompactionThresholdFromBody } from '../../endpoint/node/responsesApi'; import { ChatWebSocketRequestOutcome, ChatWebSocketTelemetrySender } from './chatWebSocketTelemetry'; @@ -680,7 +681,7 @@ class ChatWebSocketConnection extends Disposable implements IChatWebSocketConnec ...rest, initiator: options.userInitiated ? 'user' : 'agent', }; - const serializedMessage = JSON.stringify(message); + const serializedMessage = stringifyJsonBody(message); const sentMessageCharacters = serializedMessage.length; this._totalSentMessageCount += 1; this._totalSentCharacters += sentMessageCharacters; diff --git a/extensions/copilot/src/platform/networking/node/nodeFetcher.ts b/extensions/copilot/src/platform/networking/node/nodeFetcher.ts index 5d5eb91a06c..7b0d9d7be3c 100644 --- a/extensions/copilot/src/platform/networking/node/nodeFetcher.ts +++ b/extensions/copilot/src/platform/networking/node/nodeFetcher.ts @@ -10,6 +10,7 @@ import { generateUuid } from '../../../util/vs/base/common/uuid'; import { IEnvService } from '../../env/common/envService'; import { collectSingleLineErrorMessage } from '../../log/common/logService'; import { FetchOptions, HeadersImpl, IAbortController, IHeaders, PaginationOptions, ReportFetchEvent, Response, safeGetHostname } from '../common/fetcherService'; +import { stringifyJsonBody } from '../common/jsonBody'; import { IFetcher, userAgentLibraryHeader } from '../common/networking'; export class NodeFetcher implements IFetcher { @@ -40,7 +41,7 @@ export class NodeFetcher implements IFetcher { throw new Error(`Illegal arguments! Cannot pass in both 'body' and 'json'!`); } headers['Content-Type'] = 'application/json'; - body = JSON.stringify(options.json); + body = stringifyJsonBody(options.json); } const method = options.method || 'GET'; diff --git a/extensions/copilot/src/platform/networking/test/node/jsonBody.spec.ts b/extensions/copilot/src/platform/networking/test/node/jsonBody.spec.ts new file mode 100644 index 00000000000..e021240a01b --- /dev/null +++ b/extensions/copilot/src/platform/networking/test/node/jsonBody.spec.ts @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { suite, test } from 'vitest'; +import { stringifyJsonBody } from '../../common/jsonBody'; + +suite('stringifyJsonBody', () => { + + test('emits a body that a strict UTF-8 parser accepts', () => { + const body = { + // A tool result truncated in the middle of an emoji, as a character-count limit would do. + truncated: `head${'🙂'.repeat(3).slice(0, 5)}tail`, + // A lone surrogate in a property name, which a `JSON.stringify` replacer cannot reach. + ['key\uD83D']: 'value', + // A lone surrogate right after a literal backslash, so the escape it produces is preceded + // by an odd-length backslash run. + afterBackslash: '\\\uDE42', + }; + + const serialized = stringifyJsonBody(body); + + assert.deepStrictEqual( + { + hasSurrogateEscape: /\\u[dD][89a-fA-F]/.test(serialized), + survivesUtf8: Buffer.from(serialized, 'utf8').toString('utf8') === serialized, + parsed: JSON.parse(serialized), + }, + { + hasSurrogateEscape: false, + survivesUtf8: true, + parsed: { + truncated: 'head🙂🙂\uFFFDtail', + 'key\uFFFD': 'value', + afterBackslash: '\\\uFFFD', + }, + } + ); + }); + + test('matches JSON.stringify when the payload is already well-formed', () => { + // `literalEscapeText` is text rather than an escape: `JSON.stringify` doubles its backslash, + // so the sanitizing pass must consume the pair and leave it byte-for-byte identical. + const body = { emoji: 'a 🙂 b', literalEscapeText: 'not an escape: \\ud83d', control: '\n\t"' }; + + assert.strictEqual(stringifyJsonBody(body), JSON.stringify(body)); + }); + + test('scans a long run of backslashes in linear time', () => { + // Guards against reintroducing a pattern like `(\\+)u...`, whose backtracking is quadratic in + // the length of a backslash run and turns this reachable tool output into a denial of service. + // A linear scan finishes in single-digit milliseconds; the quadratic one needs over a minute, + // so the suite timeout is what fails here rather than a flaky duration assertion. + const body = { content: '\\'.repeat(200_000) + 'ud8' }; + + assert.strictEqual(stringifyJsonBody(body), JSON.stringify(body)); + }); + + test('rejects a value that has no JSON representation', () => { + const attempt = (value: unknown) => { + try { + stringifyJsonBody(value); + return 'serialized'; + } catch (error) { + return (error as Error).message; + } + }; + + assert.deepStrictEqual( + { undefined: attempt(undefined), function: attempt(() => { }), object: attempt({}) }, + { + undefined: `Illegal arguments! A value of type 'undefined' has no JSON representation!`, + function: `Illegal arguments! A value of type 'function' has no JSON representation!`, + object: 'serialized', + } + ); + }); +}); From a036a4c2490a12328a3508319859eca20a8fc52c Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 25 Aug 2026 18:43:46 +0200 Subject: [PATCH 005/116] sessions: preserve session creation provenance (#332558) * sessions: preserve session creation provenance Keep immutable creator session, chat, and turn metadata for sessions created through create_session. Use it for default list/group placement and source annotations while preserving explicit user placement, and remove the old coordination lifecycle. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: fix creation provenance regressions Restore updated-time recency after initial creator placement, map aliased Agent Host source links, document creator-hover keyboard access, and register the new renderer dependency in fixtures.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: register fixture connection service Register the Agent Host connections service required by SessionsList so all component-fixture variants render.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: persist delegated request origins Persist per-turn delegation metadata before provider send and restore it during turn hydration so create_session, create_chat, and send_message annotations survive Agent Host restarts.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: clarify Codex turn id bridge Document why Codex needs an explicit durable provider-turn mapping while Copilot and Claude already preserve restorable turn identity.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/AGENTS.md | 26 +- .../common/meta/agentMessageDelegationMeta.ts | 40 +- .../agentHost/common/openSessionLink.ts | 26 +- .../agentHost/common/sessionDataService.ts | 12 + .../agentHost/common/state/sessionState.ts | 51 +-- .../platform/agentHost/node/agentService.ts | 50 ++- .../agentHost/node/agentServiceComposition.ts | 12 - .../agentHost/node/agentServiceFoundation.ts | 3 +- .../agentHost/node/chatContributions/TODO.md | 2 + .../builtInChatContributions.ts | 2 + .../turnDelegationContribution.ts | 97 +++++ .../agentHost/node/codex/codexAgent.ts | 21 +- .../agentHost/node/sessionCoordination.ts | 159 -------- .../agentHost/node/sessionDatabase.ts | 37 ++ .../node/shared/agentServerToolHost.ts | 2 + .../node/shared/sessionServerTools.ts | 147 ++----- .../test/common/openSessionLink.test.ts | 9 +- .../test/common/sessionTestHelpers.ts | 46 ++- .../agentHost/test/node/agentService.test.ts | 95 +---- .../test/node/chatContributions.test.ts | 56 +++ .../test/node/codex/codexCreateChat.test.ts | 36 ++ ...Copilot_prompts_claude-haiku-4_5.prompt.md | 24 -- ..._Copilot_prompts_claude-opus-4_5.prompt.md | 24 -- ..._Copilot_prompts_claude-opus-4_6.prompt.md | 24 -- ..._Copilot_prompts_claude-opus-4_7.prompt.md | 24 -- ..._Copilot_prompts_claude-opus-4_8.prompt.md | 24 -- ...___Copilot_prompts_claude-opus-5.prompt.md | 24 -- ...opilot_prompts_claude-sonnet-4_5.prompt.md | 24 -- ...opilot_prompts_claude-sonnet-4_6.prompt.md | 24 -- ..._Copilot_prompts_claude-sonnet-5.prompt.md | 24 -- ...Copilot_prompts_gemini-2_0-flash.prompt.md | 24 -- ...2E___Copilot_prompts_gpt-5-codex.prompt.md | 24 -- ...E2E___Copilot_prompts_gpt-5-mini.prompt.md | 24 -- ...Host_E2E___Copilot_prompts_gpt-5.prompt.md | 24 -- ...pilot_prompts_gpt-5_1-codex-mini.prompt.md | 24 -- ...___Copilot_prompts_gpt-5_1-codex.prompt.md | 24 -- ...st_E2E___Copilot_prompts_gpt-5_1.prompt.md | 24 -- ...E___Copilot_prompts_gpt-5_6-luna.prompt.md | 24 -- ...2E___Copilot_prompts_gpt-5_6-sol.prompt.md | 24 -- ...___Copilot_prompts_gpt-5_6-terra.prompt.md | 24 -- .../test/node/e2e/suites/serverToolsSuite.ts | 16 +- .../test/node/sessionCoordination.test.ts | 70 ---- .../test/node/sessionDatabase.test.ts | 29 ++ .../test/node/sessionServerTools.test.ts | 216 ++++------- src/vs/sessions/SESSIONS.md | 7 + src/vs/sessions/SESSIONS_LIST.md | 5 + .../openSessionLinkOpener.contribution.ts | 3 +- .../requestOriginProvider.contribution.ts | 6 + .../browser/sessionsChatAccessibilityHelp.ts | 2 + .../browser/openSessionLinkOpener.test.ts | 7 +- .../browser/baseAgentHostSessionsProvider.ts | 35 +- .../localAgentHostSessionsProvider.test.ts | 82 +++- .../cloudSandboxReadOnlySessionHandler.ts | 4 +- .../remoteAgentHostSessionsProvider.ts | 5 + .../sessions/browser/sessionHoverContent.ts | 2 + .../sessions/browser/views/sessionsList.ts | 32 +- .../test/browser/automationsView.fixture.ts | 2 + .../test/browser/sessionsList.test.ts | 22 ++ .../sessions/browser/sessionGroupsService.ts | 90 ++++- .../browser/sessionsListModelService.ts | 138 ++++++- .../sessions/browser/visibleSessions.ts | 2 + .../services/sessions/common/session.ts | 8 + .../test/browser/sessionGroupsService.test.ts | 303 ++++++++++++++- .../browser/sessionsListModelService.test.ts | 365 +++++++++++++++++- .../agentHost/agentHostSessionHandler.ts | 5 +- .../agentHost/stateToProgressAdapter.ts | 32 +- .../media/sessionSummaryHover.css | 20 + .../agentSessions/sessionSummaryHover.ts | 20 + .../chatContentParts/chatRequestOriginPart.ts | 17 +- .../contrib/chat/common/chatRequestOrigin.ts | 9 +- .../stateToProgressAdapter.test.ts | 61 +++ .../chatRequestOriginPart.test.ts | 27 ++ .../test/common/chatRequestOrigin.test.ts | 10 +- .../sessions/blockedSessionsList.fixture.ts | 2 + .../sessions/sessionsList.fixture.ts | 2 + 75 files changed, 1876 insertions(+), 1165 deletions(-) create mode 100644 src/vs/platform/agentHost/node/chatContributions/turnDelegation/turnDelegationContribution.ts delete mode 100644 src/vs/platform/agentHost/node/sessionCoordination.ts delete mode 100644 src/vs/platform/agentHost/test/node/sessionCoordination.test.ts diff --git a/src/vs/platform/agentHost/AGENTS.md b/src/vs/platform/agentHost/AGENTS.md index 74d1c44cfef..aad5fbc5eb3 100644 --- a/src/vs/platform/agentHost/AGENTS.md +++ b/src/vs/platform/agentHost/AGENTS.md @@ -232,31 +232,19 @@ Provider-private discovery helpers name their concrete source: Claude uses `_lis For every provider, migration and discovery partition the same native catalog: migration returns known entries as plain metadata, while discovery emits unknown entries with provider-classified provenance (external for Claude and Codex, and for Copilot everything except an unknown legacy extension-host chat, which is emitted as internal and adoptable). The partition is not quite exhaustive for Copilot: a chat whose session database exists but holds none of the metadata keys `listChatsToMigrate` requires is rejected by both halves. That is deliberate — an empty database is how Agent Host records a chat it already touched — and is asserted by `copilotAgent.test.ts`'s "does not discover an extension-host chat with an empty Agent Host database". Central `agent-host.db` remains the durable provenance authority. -### Server-tool orchestration relationships +### Server-tool creation provenance Treat a session as the user-visible unit of work. The `create_chat` tool is the default for parallel subtasks that should share one workspace, lifecycle, and aggregate diff. Use `create_session` only when a delegated task needs an independent workspace, worktree or branch, provider, or lifecycle. -Sessions created by the `create_session` server tool record provider-neutral -orchestration metadata in the session summary `_meta` bag. The metadata names -the creating session separately from the hierarchy parent, plus an optional -label, whether the child may coordinate with its creator, and an optional -idle-notification policy. Keeping creator identity separate from hierarchy -placement preserves notification routing if parent relationships evolve. -`list_sessions` projects and filters hierarchy metadata without involving -provider harnesses. - -`SessionCoordinationService` owns idle-notification status observation, -per-child sequencing, creator restoration, and delivery. Its durable -`creatorNotificationState` is `waitingForCompletion` after work starts and -`notified` after the next input-needed/idle/error transition wakes the creator. -The `always` policy returns to `waitingForCompletion` on the next work cycle. A -busy creator default chat receives a queued system notification rather than a -new active turn, so concurrent child completion cannot overwrite creator work. -The existing pending-message drain starts that queued notification when the -creator chat becomes idle. +Sessions created by the `create_session` server tool record only the creating +session, chat, and turn as immutable, provider-neutral creation provenance in +the initial session summary `_meta` bag, before the session is published or its +first prompt starts. The reference supports related-session placement, +source identification and session-list presentation; it does not define a +hierarchy, grant communication privileges, or trigger lifecycle notifications. `list_sessions` exposes a session's configured project URI separately from its primary and additional working directories. `create_session` accepts those URIs diff --git a/src/vs/platform/agentHost/common/meta/agentMessageDelegationMeta.ts b/src/vs/platform/agentHost/common/meta/agentMessageDelegationMeta.ts index d1b18d16bae..7be08f633d0 100644 --- a/src/vs/platform/agentHost/common/meta/agentMessageDelegationMeta.ts +++ b/src/vs/platform/agentHost/common/meta/agentMessageDelegationMeta.ts @@ -9,22 +9,46 @@ interface IHasMessageDelegationMeta { readonly _meta?: Record; } -export interface IAgentMessageDelegationMeta { +export interface IAgentMessageThreadDelegationMeta { readonly sourceThreadId: string; } +export interface IAgentMessageSessionDelegationMeta { + readonly sourceSession: string; + readonly sourceChat?: string; + readonly sourceTurnId?: string; +} + +export type IAgentMessageDelegationMeta = IAgentMessageThreadDelegationMeta | IAgentMessageSessionDelegationMeta; + +/** Parses recognized Agent Host message-delegation metadata. */ +export function parseAgentMessageDelegationMeta(value: unknown): IAgentMessageDelegationMeta | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const candidate = value as Record; + const sourceThreadId = candidate['sourceThreadId']; + if (typeof sourceThreadId === 'string' && sourceThreadId.length > 0) { + return { sourceThreadId }; + } + const sourceSession = candidate['sourceSession']; + if (typeof sourceSession !== 'string' || sourceSession.length === 0) { + return undefined; + } + return { + sourceSession, + ...(typeof candidate['sourceChat'] === 'string' ? { sourceChat: candidate['sourceChat'] } : {}), + ...(typeof candidate['sourceTurnId'] === 'string' ? { sourceTurnId: candidate['sourceTurnId'] } : {}), + }; +} + /** Reads recognized Agent Host message-delegation metadata. */ export function readAgentMessageDelegationMeta(source: IHasMessageDelegationMeta): IAgentMessageDelegationMeta | undefined { // eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned first hop into the namespaced delegation slot; validated below. - const value = source._meta?.[MESSAGE_DELEGATION_META_KEY]; - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return undefined; - } - const sourceThreadId = (value as Record)['sourceThreadId']; - return typeof sourceThreadId === 'string' && sourceThreadId.length > 0 ? { sourceThreadId } : undefined; + return parseAgentMessageDelegationMeta(source._meta?.[MESSAGE_DELEGATION_META_KEY]); } /** Serializes Agent Host message-delegation metadata for the open protocol bag. */ export function toAgentMessageDelegationMeta(meta: IAgentMessageDelegationMeta): Record { - return { [MESSAGE_DELEGATION_META_KEY]: { sourceThreadId: meta.sourceThreadId } }; + return { [MESSAGE_DELEGATION_META_KEY]: meta }; } diff --git a/src/vs/platform/agentHost/common/openSessionLink.ts b/src/vs/platform/agentHost/common/openSessionLink.ts index f806de68711..eaf9f8002af 100644 --- a/src/vs/platform/agentHost/common/openSessionLink.ts +++ b/src/vs/platform/agentHost/common/openSessionLink.ts @@ -84,14 +84,21 @@ export function isSendMessageTool(toolName: string): boolean { * ecosystem-wide invariant (an absent chat id already means "the default chat"), * so it is enforced here once rather than at each call site. */ -export function buildOpenSessionLinkUri(backendSession: URI | string, chatId?: string): string { +export function buildOpenSessionLinkUri(backendSession: URI | string, chatId?: string, turnId?: string): string { const provider = AgentSession.provider(backendSession); const rawId = AgentSession.id(backendSession); if (!provider) { throw new Error(`Cannot build open-session link: missing provider in ${backendSession.toString()}`); } const base = URI.from({ scheme: AGENT_HOST_SESSION_LINK_SCHEME, authority: provider, path: `/${rawId}` }).toString(); - return chatId && chatId !== DEFAULT_CHAT_ID ? `${base}?chat=${encodeURIComponent(chatId)}` : base; + const query: string[] = []; + if (chatId && chatId !== DEFAULT_CHAT_ID) { + query.push(`chat=${encodeURIComponent(chatId)}`); + } + if (turnId) { + query.push(`turn=${encodeURIComponent(turnId)}`); + } + return query.length > 0 ? `${base}?${query.join('&')}` : base; } /** @@ -121,18 +128,25 @@ export function parseOpenSessionLinkUri(uri: URI | string): URI | undefined { * links resolving to the default chat. */ export function parseOpenSessionLinkChatId(uri: URI | string): string | undefined { + const chatId = readOpenSessionLinkQueryParam(uri, 'chat'); + return chatId === DEFAULT_CHAT_ID ? undefined : chatId; +} + +export function parseOpenSessionLinkTurnId(uri: URI | string): string | undefined { + return readOpenSessionLinkQueryParam(uri, 'turn'); +} + +function readOpenSessionLinkQueryParam(uri: URI | string, name: string): string | undefined { const parsed = typeof uri === 'string' ? URI.parse(uri) : uri; if (parsed.scheme !== AGENT_HOST_SESSION_LINK_SCHEME) { return undefined; } - const match = /(?:^|&)chat=([^&]+)/.exec(parsed.query); - let chatId: string | undefined; + const match = new RegExp(`(?:^|&)${name}=([^&]+)`).exec(parsed.query); try { - chatId = match ? decodeURIComponent(match[1]) : undefined; + return match ? decodeURIComponent(match[1]) : undefined; } catch { return undefined; } - return chatId === DEFAULT_CHAT_ID ? undefined : chatId; } /** diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index 7f78220de4d..6e050039e56 100644 --- a/src/vs/platform/agentHost/common/sessionDataService.ts +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -168,6 +168,18 @@ export interface ISessionDatabase extends IDisposable { */ getTurnUsages(): Promise>; + /** + * Persists the JSON-serialized delegation metadata for an agent-authored turn. + * Idempotent — last writer wins per turn. + */ + setTurnDelegation(turnId: string, delegation: string): Promise; + + /** + * Returns every persisted turn delegation, keyed by both the turn's own id + * and its provider event id when one has been recorded. + */ + getTurnDelegations(): Promise>; + /** * Associates a git checkpoint ref (e.g. `refs/agents//checkpoints/turn/N`) * with a turn. Idempotent — last writer wins per turn. diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 6727ebc01dd..80ebdf7d035 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -1753,60 +1753,47 @@ export function withSessionSpawnDepth(meta: SessionSummaryMeta | undefined, dept return { ...meta, [SESSION_META_SPAWN_DEPTH_KEY]: depth }; } -export type SessionIdleNotification = 'once' | 'always'; -export type SessionCreatorNotificationState = 'waitingForCompletion' | 'notified'; +export const SESSION_META_CREATED_BY_SESSION_KEY = 'agentHost/createdBySession'; +export const AH_META_CREATED_BY_SESSION_DB_KEY = 'agentHost.createdBySession'; -export interface ISessionOrchestration { - readonly parentSession: string; - readonly creatorSession: string; - readonly label?: string; - readonly coordinateWithCreator: boolean; - readonly notifyOnIdle?: SessionIdleNotification; - /** Durable delivery state used to wait for a work outcome and deduplicate replayed statuses. */ - readonly creatorNotificationState?: SessionCreatorNotificationState; +export interface ISessionCreationReference { + readonly session: string; + readonly chat?: string; + readonly turnId?: string; } -export const SESSION_META_ORCHESTRATION_KEY = 'agentHost/orchestration'; -export const AH_META_ORCHESTRATION_DB_KEY = 'agentHost.orchestration'; +export function readSessionCreationReference(meta: SessionSummaryMeta | undefined): ISessionCreationReference | undefined { + return parseSessionCreationReferenceValue(meta?.[SESSION_META_CREATED_BY_SESSION_KEY]); +} -export function readSessionOrchestration(meta: SessionSummaryMeta | undefined): ISessionOrchestration | undefined { - const value = meta?.[SESSION_META_ORCHESTRATION_KEY]; +function parseSessionCreationReferenceValue(value: unknown): ISessionCreationReference | undefined { if (!value || typeof value !== 'object') { return undefined; } const candidate = value as { [key: string]: unknown }; - if (typeof candidate.parentSession !== 'string' || typeof candidate.coordinateWithCreator !== 'boolean') { + if (typeof candidate.session !== 'string') { return undefined; } - const creatorSession = typeof candidate.creatorSession === 'string' ? candidate.creatorSession : candidate.parentSession; - const label = typeof candidate.label === 'string' ? candidate.label : undefined; - const notifyOnIdle = candidate.notifyOnIdle === 'once' || candidate.notifyOnIdle === 'always' ? candidate.notifyOnIdle : undefined; - const creatorNotificationState = candidate.creatorNotificationState === 'waitingForCompletion' || candidate.creatorNotificationState === 'notified' - ? candidate.creatorNotificationState - : undefined; return { - parentSession: candidate.parentSession, - creatorSession, - coordinateWithCreator: candidate.coordinateWithCreator, - ...(label !== undefined ? { label } : {}), - ...(notifyOnIdle !== undefined ? { notifyOnIdle } : {}), - ...(creatorNotificationState !== undefined ? { creatorNotificationState } : {}), + session: candidate.session, + ...(typeof candidate.chat === 'string' ? { chat: candidate.chat } : {}), + ...(typeof candidate.turnId === 'string' ? { turnId: candidate.turnId } : {}), }; } -export function parseSessionOrchestration(value: string | undefined): ISessionOrchestration | undefined { - if (value === undefined) { +export function parseSessionCreationReference(value: string | undefined): ISessionCreationReference | undefined { + if (!value) { return undefined; } try { - return readSessionOrchestration({ [SESSION_META_ORCHESTRATION_KEY]: JSON.parse(value) }); + return readSessionCreationReference({ [SESSION_META_CREATED_BY_SESSION_KEY]: JSON.parse(value) }); } catch { return undefined; } } -export function withSessionOrchestration(meta: SessionSummaryMeta | undefined, orchestration: ISessionOrchestration): SessionSummaryMeta { - return { ...meta, [SESSION_META_ORCHESTRATION_KEY]: orchestration }; +export function withSessionCreationReference(meta: SessionSummaryMeta | undefined, creationReference: ISessionCreationReference): SessionSummaryMeta { + return { ...meta, [SESSION_META_CREATED_BY_SESSION_KEY]: creationReference }; } /** diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index afaed93c53a..d9a9ce308f1 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -35,10 +35,11 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type Annotation, type AnnotationEntry, type AnnotationOrigin, type AnnotationsState, type ChatOrigin, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment, type TextRange } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js'; -import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_ORCHESTRATION_DB_KEY, readSessionSpawnDepth, parseSessionOrchestration, withSessionSpawnDepth, withSessionOrchestration, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; +import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../common/meta/agentSnapshotAttachmentMeta.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js'; +import { IAgentMessageDelegationMeta, toAgentMessageDelegationMeta } from '../common/meta/agentMessageDelegationMeta.js'; import { readChatSurfaceMeta, withChatSurfaceMeta } from '../common/meta/agentChatSurfaceMeta.js'; import { AgentConfigurationService, getEffectiveWorkingDirectories } from './agentConfigurationService.js'; import { IAgentHostTerminalManager } from './agentHostTerminalManager.js'; @@ -82,7 +83,6 @@ import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostArtifactToolsConfigKey, AgentHostEditTelemetryEnabledConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostShowExternalSessionsConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; -import { SessionCoordinationService } from './sessionCoordination.js'; import { IAgentHostChangesetService, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js'; import { GIT_DB_METADATA_KEYS, IAgentHostGitStateService, META_GIT_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../common/agentHostGitStateService.js'; import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; @@ -388,7 +388,6 @@ export interface IAgentServiceCollaborators { readonly terminalManager: IAgentHostTerminalManager; readonly localTurns: AgentHostLocalTurns; readonly sideEffects: AgentSideEffects; - readonly sessionCoordination: SessionCoordinationService; readonly serverToolHost: AgentServerToolHost; } @@ -429,7 +428,6 @@ export class AgentService extends Disposable implements IAgentService { /** Authoritative state manager for the sessions process protocol. */ private readonly _stateManager: AgentHostStateManager; - private readonly _sessionCoordination: SessionCoordinationService; /** * Orchestrator-owned durable index of known sessions. Populated alongside @@ -609,7 +607,6 @@ export class AgentService extends Disposable implements IAgentService { this._terminalManager = collaborators.terminalManager; this._localTurns = collaborators.localTurns; this._sideEffects = collaborators.sideEffects; - this._sessionCoordination = collaborators.sessionCoordination; this._serverToolHost = collaborators.serverToolHost; this._sessionResidency = this._register(instantiationService.createInstance( AgentSessionResidency, @@ -1097,7 +1094,7 @@ export class AgentService extends Disposable implements IAgentService { return models; }, getCreationDefaults: source => this._getServerToolCreationDefaults(source), - startPrompt: (session, chat, prompt) => this._startSessionPrompt(session, chat, prompt), + startPrompt: (session, chat, prompt, delegation) => this._startSessionPrompt(session, chat, prompt, delegation), createChat: (session, chat, options) => this.createChat(session, chat, (options?.title !== undefined || options?.model !== undefined) ? { ...(options.title !== undefined ? { title: options.title } : {}), ...(options.model !== undefined ? { model: options.model } : {}) } : undefined), @@ -1112,7 +1109,6 @@ export class AgentService extends Disposable implements IAgentService { type: ActionType.SessionMetaChanged, _meta: withSessionSpawnDepth(this._stateManager.getSessionSummary(session.toString())?._meta, depth), }), - setSessionOrchestration: (session, orchestration) => this._sessionCoordination.setOrchestration(session.toString(), orchestration), }; } @@ -1157,9 +1153,12 @@ export class AgentService extends Disposable implements IAgentService { * `ChatTurnStarted` and routing it through the same side-effects path a * client-initiated turn takes (which sends the message to the provider). */ - private async _startSessionPrompt(session: URI, chat: URI, prompt: string): Promise { - // The calling agent authored this prompt, not the user. - const message: Message = { text: prompt, origin: { kind: MessageKind.Agent } }; + private async _startSessionPrompt(session: URI, chat: URI, prompt: string, delegation?: IAgentMessageDelegationMeta): Promise { + const message: Message = { + text: prompt, + origin: { kind: MessageKind.Agent }, + ...(delegation ? { _meta: toAgentMessageDelegationMeta(delegation) } : {}), + }; const action = { type: ActionType.ChatTurnStarted, turnId: generateUuid(), startedAt: new Date().toISOString(), message } as const; this._stateManager.dispatchServerAction(chat.toString(), action); this._sideEffects.handleAction(chat.toString(), action); @@ -1913,8 +1912,8 @@ export class AgentService extends Disposable implements IAgentService { const sessionStr = s.session.toString(); const changesetKeys = this._changesetCoordinator.getListMetadataKeys(sessionStr); const metadataKeys: Record = changesetKeys - ? { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS, ...changesetKeys } - : { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS }; + ? { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_CREATED_BY_SESSION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS, ...changesetKeys } + : { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_CREATED_BY_SESSION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS }; const m = await ref.object.getMetadataObject(metadataKeys); // This session is an internal peer-chat backing (e.g. a // Claude peer chat's SDK session, enumerated by the agent's @@ -1936,9 +1935,9 @@ export class AgentService extends Disposable implements IAgentService { if (persistedArchived !== undefined) { updated = { ...updated, status: withSessionStatusFlag(updated.status ?? SessionStatus.Idle, SessionStatus.IsArchived, persistedArchived === 'true') }; } - const orchestration = parseSessionOrchestration(m[AH_META_ORCHESTRATION_DB_KEY]); - if (orchestration) { - updated = { ...updated, _meta: withSessionOrchestration(updated._meta, orchestration) }; + const creationReference = parseSessionCreationReference(m[AH_META_CREATED_BY_SESSION_DB_KEY]); + if (creationReference) { + updated = { ...updated, _meta: withSessionCreationReference(updated._meta, creationReference) }; } if (m[META_GIT_STATE]) { try { @@ -2532,6 +2531,17 @@ export class AgentService extends Disposable implements IAgentService { ]); const session = created.session; this._logService.trace(`[AgentService] createSession: initialization complete`); + const creationReference = readSessionCreationReference(config?._meta); + if (creationReference && !isEphemeral) { + try { + await persistSessionMetadataValues(this._sessionDataService, session.toString(), { + [AH_META_CREATED_BY_SESSION_DB_KEY]: JSON.stringify(creationReference), + }); + } catch (err) { + await this._rollbackProviderSession(provider, session); + throw err; + } + } if (isEphemeral) { try { await this._retryRegistryMutation( @@ -3261,6 +3271,8 @@ export class AgentService extends Disposable implements IAgentService { _meta = withEphemeralSessionMeta(_meta, config ? readEphemeralSessionMeta(config).isEphemeral : undefined); _meta = withChatSurfaceMeta(_meta, readChatSurfaceMeta(config ?? {})); _meta = withSessionExternal(_meta, false); + const creationReference = readSessionCreationReference(config?._meta); + _meta = creationReference ? withSessionCreationReference(_meta, creationReference) : _meta; _meta = !config?.workingDirectories ? withSessionWorkspaceless(_meta, true) : _meta; @@ -4913,7 +4925,7 @@ export class AgentService extends Disposable implements IAgentService { configValues: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, - [AH_META_ORCHESTRATION_DB_KEY]: true, + [AH_META_CREATED_BY_SESSION_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, @@ -4977,9 +4989,9 @@ export class AgentService extends Disposable implements IAgentService { if (m[AH_META_EHCLI_ADOPTED_DB_KEY] !== undefined) { sessionMetadata = withSessionEhcliAdopted(sessionMetadata, m[AH_META_EHCLI_ADOPTED_DB_KEY] === 'true'); } - const orchestration = parseSessionOrchestration(m[AH_META_ORCHESTRATION_DB_KEY]); - if (orchestration) { - sessionMetadata = withSessionOrchestration(sessionMetadata, orchestration); + const creationReference = parseSessionCreationReference(m[AH_META_CREATED_BY_SESSION_DB_KEY]); + if (creationReference) { + sessionMetadata = withSessionCreationReference(sessionMetadata, creationReference); } sessionMetadata = withSessionMultiRootMetadata(sessionMetadata, parseSessionMultiRootMetadata(m[SESSION_META_MULTI_ROOT_KEY])); sessionMetadata = withSessionArtifacts(sessionMetadata, parseSessionArtifacts(m[SESSION_ARTIFACTS_KEY])); diff --git a/src/vs/platform/agentHost/node/agentServiceComposition.ts b/src/vs/platform/agentHost/node/agentServiceComposition.ts index d540552afb3..182e505a828 100644 --- a/src/vs/platform/agentHost/node/agentServiceComposition.ts +++ b/src/vs/platform/agentHost/node/agentServiceComposition.ts @@ -32,7 +32,6 @@ import { AgentMergeTools } from './agentMergeTools.js'; import { AgentService, type IAgentServiceCollaborators, type IAgentServiceCore, type IAgentServiceOptions } from './agentService.js'; import { AgentSessionRegistry } from './agentSessionRegistry.js'; import { AgentSideEffects } from './agentSideEffects.js'; -import { SessionCoordinationService } from './sessionCoordination.js'; import { AgentServerToolHost } from './shared/agentServerToolHost.js'; import { buildServerToolGroups } from './shared/serverToolGroups.js'; import { type IAgentServiceFoundation } from './agentServiceFoundation.js'; @@ -130,16 +129,6 @@ export function createAgentServiceComposition( resolveChatAttachmentTurns: resource => callbackAdapter.value.resolveChatAttachmentTurns(resource), }, )); - const sessionCoordination = owned.add(new SessionCoordinationService( - stateManager, - sessionDataService, - logService, - { - getSessionMetadata: session => callbackAdapter.value.getSessionMetadata(session), - restoreSession: session => callbackAdapter.value.restoreSession(session), - handleAction: (chat, action) => sideEffects.handleAction(chat, action), - }, - )); const agentMergeTools = instantiationService.createInstance( AgentMergeTools, () => agentMergeController.isEnabled(), @@ -163,7 +152,6 @@ export function createAgentServiceComposition( terminalManager, localTurns, sideEffects, - sessionCoordination, serverToolHost, }; agentService = instantiationService.createInstance(AgentService, core, collaborators, options); diff --git a/src/vs/platform/agentHost/node/agentServiceFoundation.ts b/src/vs/platform/agentHost/node/agentServiceFoundation.ts index 07489e41626..d3e50fd7321 100644 --- a/src/vs/platform/agentHost/node/agentServiceFoundation.ts +++ b/src/vs/platform/agentHost/node/agentServiceFoundation.ts @@ -35,7 +35,7 @@ export class AgentServiceCallbackAdapter implements IAgentServiceCallbackBinder createSession: config => this.value.sessionServerToolAccessor.createSession(config), getModels: () => this.value.sessionServerToolAccessor.getModels(), getCreationDefaults: source => this.value.sessionServerToolAccessor.getCreationDefaults(source), - startPrompt: (session, chat, prompt) => this.value.sessionServerToolAccessor.startPrompt(session, chat, prompt), + startPrompt: (session, chat, prompt, delegation) => this.value.sessionServerToolAccessor.startPrompt(session, chat, prompt, delegation), createChat: (session, chat, options) => this.value.sessionServerToolAccessor.createChat(session, chat, options), renameChat: (session, chat, title) => this.value.sessionServerToolAccessor.renameChat(session, chat, title), reportToolError: (toolName, error) => this.value.sessionServerToolAccessor.reportToolError(toolName, error), @@ -43,7 +43,6 @@ export class AgentServiceCallbackAdapter implements IAgentServiceCallbackBinder getChatContext: (session, chatId) => this.value.sessionServerToolAccessor.getChatContext(session, chatId), getSessionSpawnDepth: session => this.value.sessionServerToolAccessor.getSessionSpawnDepth(session), setSessionSpawnDepth: (session, depth) => this.value.sessionServerToolAccessor.setSessionSpawnDepth(session, depth), - setSessionOrchestration: (session, orchestration) => this.value.sessionServerToolAccessor.setSessionOrchestration(session, orchestration), }; readonly artifactServerToolAccessor: IArtifactServerToolAccessor = { diff --git a/src/vs/platform/agentHost/node/chatContributions/TODO.md b/src/vs/platform/agentHost/node/chatContributions/TODO.md index 75741fca5dd..2d110f09ed2 100644 --- a/src/vs/platform/agentHost/node/chatContributions/TODO.md +++ b/src/vs/platform/agentHost/node/chatContributions/TODO.md @@ -22,6 +22,7 @@ Each contribution has its own subfolder so its implementation, helpers, and test ## Completed `onOutgoingTurn` extractions +- `turnDelegation` (order 50) — persists agent-authored delegation metadata before provider send so replay can restore request origins. - `markdownPlanRichLinks` (order 100) — adds Markdown plan rich-link guidance when `AgentHostMarkdownPlanRichLinksEnabledConfigKey` is enabled. - `artifactTools` (order 200) — adds artifact-tool guidance when `AgentHostArtifactToolsConfigKey` is enabled. - `chatSurface` (order 300) — adds terminal or editor-inline guidance from the session surface metadata. @@ -34,6 +35,7 @@ Each contribution has its own subfolder so its implementation, helpers, and test ## Completed `onHydrateTurns` extractions +- `turnDelegation` (order 50) — restores agent authorship and delegation metadata by host or provider turn id. - `persistedTurnUsage` (order 100) — restores persisted per-turn usage with one database read for the complete list. - `worktreeAnnouncement` (order 200) — restores the isolated-worktree notice for default chats through `IAgentHostWorktreeIsolation`. - Hydration reuses the spaced 100-series independently from turn-end and outgoing-turn hooks, because ordering is per hook. diff --git a/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts b/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts index d047a5df4a3..0746b01008a 100644 --- a/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts +++ b/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts @@ -15,6 +15,7 @@ import { PersistedTurnUsageContribution } from './persistedTurnUsage/persistedTu import { QueueDrainContribution } from './queueDrain/queueDrainContribution.js'; import { SessionTitleContribution } from './sessionTitle/sessionTitleContribution.js'; import { SideChatContribution } from './sideChat/sideChatContribution.js'; +import { TurnDelegationContribution } from './turnDelegation/turnDelegationContribution.js'; import { WorktreeAnnouncementContribution } from './worktreeAnnouncement/worktreeAnnouncementContribution.js'; /** Registers all built-in chat contribution constructors. */ @@ -22,6 +23,7 @@ export function registerBuiltInChatContributions( contributions: IAgentHostChatContributions, ): IDisposable { const registrations = new DisposableStore(); + registrations.add(contributions.registerContribution(TurnDelegationContribution)); registrations.add(contributions.registerContribution(PersistedTurnUsageContribution)); registrations.add(contributions.registerContribution(WorktreeAnnouncementContribution)); registrations.add(contributions.registerContribution(CheckpointAndChangesetContribution)); diff --git a/src/vs/platform/agentHost/node/chatContributions/turnDelegation/turnDelegationContribution.ts b/src/vs/platform/agentHost/node/chatContributions/turnDelegation/turnDelegationContribution.ts new file mode 100644 index 00000000000..9db46411b6a --- /dev/null +++ b/src/vs/platform/agentHost/node/chatContributions/turnDelegation/turnDelegationContribution.ts @@ -0,0 +1,97 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ILogService } from '../../../../log/common/log.js'; +import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IHydrationContext, IOutgoingTurn } from '../../../common/agentHostChatContributionsService.js'; +import { parseAgentMessageDelegationMeta, readAgentMessageDelegationMeta, toAgentMessageDelegationMeta } from '../../../common/meta/agentMessageDelegationMeta.js'; +import { ISessionDataService } from '../../../common/sessionDataService.js'; +import { chatStorageUri, MessageKind, type Turn } from '../../../common/state/sessionState.js'; + +/** Persists agent-authored turn delegation and restores it after provider replay. */ +export class TurnDelegationContribution extends Disposable implements IAgentHostChatContribution { + + static readonly id = 'turnDelegation'; + readonly order = 50; + + constructor( + protected readonly _context: IAgentHostChatContributionContext, + @ILogService private readonly _logService: ILogService, + @ISessionDataService private readonly _sessionDataService: ISessionDataService, + ) { + super(); + } + + async onOutgoingTurn(turn: IOutgoingTurn): Promise { + const delegation = readAgentMessageDelegationMeta(turn.message); + if (!delegation) { + return undefined; + } + const storage = chatStorageUri(turn.chat); + if (!storage) { + return undefined; + } + const ref = this._sessionDataService.openDatabase(storage); + try { + await ref.object.setTurnDelegation(turn.turnId, JSON.stringify(delegation)); + } finally { + ref.dispose(); + } + return undefined; + } + + async onHydrateTurns(context: IHydrationContext, turns: readonly Turn[]): Promise { + if (turns.length === 0) { + return turns; + } + const storage = chatStorageUri(URI.parse(context.chat)); + if (!storage) { + return turns; + } + const ref = await this._sessionDataService.tryOpenDatabase(storage); + if (!ref) { + return turns; + } + let delegations: Map; + try { + delegations = await ref.object.getTurnDelegations(); + } catch (error) { + this._logService.warn(`[TurnDelegationContribution] Failed to restore turn delegation for ${storage.toString()}`, error); + return turns; + } finally { + ref.dispose(); + } + if (delegations.size === 0) { + return turns; + } + return turns.map(turn => { + const raw = delegations.get(turn.id); + if (!raw) { + return turn; + } + let delegation; + try { + delegation = parseAgentMessageDelegationMeta(JSON.parse(raw)); + } catch { + return turn; + } + if (!delegation) { + return turn; + } + return { + ...turn, + message: { + ...turn.message, + origin: { kind: MessageKind.Agent }, + _meta: { + ...turn.message._meta, + ...toAgentMessageDelegationMeta(delegation), + }, + }, + }; + }); + } +} diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index e2c9c3bfae9..d9ad1bafe54 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -36,7 +36,7 @@ import { ActionType, isChatAction, type SessionAction, type ChatAction } from '. import { parseLeadingSlashCommand } from '../../common/agentHostSlashCommand.js'; import type { ConfigSchema, ModelSelection, ProtectedResourceMetadata, ToolDefinition, AgentSelection } from '../../common/state/protocol/state.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; -import { buildDefaultChatUri, isDefaultChatUri, parseRequiredSessionUriFromChatUri, withSessionWorkspaceless, CustomizationType, type ClientPluginCustomization, type DirectoryCustomization, type ISessionFolderPickerDecision, type McpServerCustomization, type MessageAttachment, type PendingMessage, type ChatInputAnswer, ChatInputResponseKind, type PluginCustomization, type PolicyState, type ToolCallResult, ToolResultContentType, type Turn, ResponsePartKind } from '../../common/state/sessionState.js'; +import { buildDefaultChatUri, chatStorageUri, isDefaultChatUri, parseRequiredSessionUriFromChatUri, withSessionWorkspaceless, CustomizationType, type ClientPluginCustomization, type DirectoryCustomization, type ISessionFolderPickerDecision, type McpServerCustomization, type MessageAttachment, type PendingMessage, type ChatInputAnswer, ChatInputResponseKind, type PluginCustomization, type PolicyState, type ToolCallResult, ToolResultContentType, type Turn, ResponsePartKind } from '../../common/state/sessionState.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { ActiveClientToolSet } from '../activeClientState.js'; import { McpCustomizationController } from '../shared/mcpCustomizationController.js'; @@ -63,6 +63,7 @@ import { IAgentHostSessionTitleSignal } from '../agentHostSessionTitleSignal.js' import { IAgentHostProxyResolver } from '../agentHostProxyResolver.js'; import { MODEL_REFRESH_BASE_DELAY_MS, MODEL_REFRESH_MAX_ATTEMPTS, MODEL_REFRESH_MAX_DELAY_MS, modelRefreshBackoff } from '../shared/modelRefreshRetry.js'; import { IAgentHostCheckpointService } from '../../common/agentHostCheckpointService.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; import { ICopilotApiService } from '../shared/copilotApiService.js'; import { extractForwardedErrorInfo } from '../shared/proxyChatError.js'; import { IAgentHostWorktreeIsolation, type IAgentHostWorktreePendingState } from '../shared/worktreeIsolation.js'; @@ -1120,6 +1121,7 @@ export class CodexAgent extends Disposable implements IAgent { @IAgentHostCustomizationEnablementService private readonly _customizationEnablementService: IAgentHostCustomizationEnablementService, @IAgentHostSessionTitleSignal sessionTitleSignal: IAgentHostSessionTitleSignal, @IAgentHostWorktreeIsolation worktree: IAgentHostWorktreeIsolation, + @ISessionDataService private readonly _sessionDataService: ISessionDataService, ) { super(); this._worktree = worktree; @@ -2512,10 +2514,25 @@ export class CodexAgent extends Disposable implements IAgent { private _handleTurnStartedNotification(session: ICodexSession, params: TurnStartedNotification): (SessionAction | ChatAction)[] { // The workbench already dispatched the canonical turn start before sendMessage. // Codex's event only establishes app-server turn id correlation for later items. - mapTurnStarted(session.mapState, this._withHostTurn(session, params), session.lastPromptText); + const appTurnId = params.turn.id; + const mapped = this._withHostTurn(session, params); + this._persistTurnEventId(session, mapped.turn.id, appTurnId); + mapTurnStarted(session.mapState, mapped, session.lastPromptText); return []; } + private _persistTurnEventId(session: ICodexSession, hostTurnId: string, appTurnId: string): void { + // Copilot already records this bridge, while Claude reuses the host turn id as its transcript uuid. + const storage = session.chatChannel ? chatStorageUri(session.chatChannel) : undefined; + if (!storage) { + return; + } + const ref = this._sessionDataService.openDatabase(storage); + ref.object.setTurnEventId(hostTurnId, appTurnId).catch(error => { + this._logService.warn(`[Codex:${session.threadId}] Failed to persist turn id mapping ${hostTurnId} -> ${appTurnId}`, error); + }).finally(() => ref.dispose()); + } + private _handleTurnCompletedNotification(session: ICodexSession, params: TurnCompletedNotification): (SessionAction | ChatAction)[] { const appTurnId = params.turn.id; const hostTurnId = this._hostTurnId(session, appTurnId); diff --git a/src/vs/platform/agentHost/node/sessionCoordination.ts b/src/vs/platform/agentHost/node/sessionCoordination.ts deleted file mode 100644 index eb12cf10a3d..00000000000 --- a/src/vs/platform/agentHost/node/sessionCoordination.ts +++ /dev/null @@ -1,159 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { toErrorMessage } from '../../../base/common/errorMessage.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import { URI } from '../../../base/common/uri.js'; -import { generateUuid } from '../../../base/common/uuid.js'; -import { ILogService } from '../../log/common/log.js'; -import { ISessionDataService } from '../common/sessionDataService.js'; -import { ActionType, type ChatTurnStartedAction } from '../common/state/sessionActions.js'; -import { MessageKind, PendingMessageKind, AH_META_ORCHESTRATION_DB_KEY, buildDefaultChatUri, readSessionOrchestration, type ISessionOrchestration, SessionStatus, withSessionOrchestration } from '../common/state/sessionState.js'; -import { type Message } from '../common/state/protocol/state.js'; -import { AgentHostStateManager } from './agentHostStateManager.js'; -import { persistSessionMetadataValues } from './shared/persistSessionMetadata.js'; - -export interface ISessionCoordinationTransition { - readonly orchestration?: ISessionOrchestration; - readonly notify: boolean; -} - -export function transitionSessionCoordination(status: SessionStatus, orchestration: ISessionOrchestration): ISessionCoordinationTransition { - if (!orchestration.notifyOnIdle) { - return { notify: false }; - } - - const inputNeeded = (status & SessionStatus.InputNeeded) === SessionStatus.InputNeeded; - const inProgress = !inputNeeded && (status & SessionStatus.InProgress) === SessionStatus.InProgress - && (status & SessionStatus.Error) !== SessionStatus.Error; - if (inProgress) { - if (orchestration.creatorNotificationState !== 'waitingForCompletion' - && !(orchestration.notifyOnIdle === 'once' && orchestration.creatorNotificationState === 'notified')) { - return { orchestration: { ...orchestration, creatorNotificationState: 'waitingForCompletion' }, notify: false }; - } - return { notify: false }; - } - - const completed = inputNeeded - || (status & SessionStatus.Idle) === SessionStatus.Idle - || (status & SessionStatus.Error) === SessionStatus.Error; - if (!completed || orchestration.creatorNotificationState !== 'waitingForCompletion') { - return { notify: false }; - } - - return { - orchestration: { - ...orchestration, - creatorNotificationState: 'notified', - }, - notify: true, - }; -} - -export interface ISessionCoordinationDelegate { - readonly getSessionMetadata: (session: URI) => Promise<{ readonly status?: SessionStatus } | undefined>; - readonly restoreSession: (session: URI) => Promise; - readonly handleAction: (chat: string, action: ChatTurnStartedAction) => void; -} - -export class SessionCoordinationService extends Disposable { - - private readonly _queues = new Map>(); - - constructor( - private readonly _stateManager: AgentHostStateManager, - private readonly _sessionDataService: ISessionDataService, - private readonly _logService: ILogService, - private readonly _delegate: ISessionCoordinationDelegate, - ) { - super(); - this._register(this._stateManager.onDidChangeSessionStatus(({ session, status }) => this._queueStatusChange(session, status))); - } - - async setOrchestration(session: string, orchestration: ISessionOrchestration): Promise { - await persistSessionMetadataValues(this._sessionDataService, session, { - [AH_META_ORCHESTRATION_DB_KEY]: JSON.stringify(orchestration), - }); - this._stateManager.setSessionMeta(session, withSessionOrchestration(this._stateManager.getSessionSummary(session)?._meta, orchestration)); - } - - async handleStatusChange(session: string, status: SessionStatus): Promise { - const summary = this._stateManager.getSessionSummary(session); - const orchestration = readSessionOrchestration(summary?._meta); - if (!summary || !orchestration?.notifyOnIdle) { - return; - } - - const transition = transitionSessionCoordination(status, orchestration); - if (!transition.notify) { - if (transition.orchestration) { - await this.setOrchestration(session, transition.orchestration); - } - return; - } - - const creator = URI.parse(orchestration.creatorSession); - const creatorMetadata = await this._delegate.getSessionMetadata(creator); - if (!creatorMetadata || (creatorMetadata.status !== undefined && (creatorMetadata.status & SessionStatus.IsArchived) === SessionStatus.IsArchived)) { - return; - } - if (!this._stateManager.getSessionState(creator.toString())) { - try { - await this._delegate.restoreSession(creator); - } catch (error) { - this._logService.error(`[SessionCoordinationService] Failed to restore creator session ${creator.toString()} for child notification: ${toErrorMessage(error)}`); - return; - } - } - const creatorSummary = this._stateManager.getSessionSummary(creator.toString()); - if (!creatorSummary || (creatorSummary.status & SessionStatus.IsArchived) === SessionStatus.IsArchived) { - return; - } - - const outcome = (status & SessionStatus.InputNeeded) === SessionStatus.InputNeeded - ? 'needs input' - : (status & SessionStatus.Error) === SessionStatus.Error ? 'encountered an error' : 'became idle'; - const childName = orchestration.label ? `${orchestration.label} (${session})` : session; - this._startPrompt(creator, `Child session ${childName} ${outcome}. Use get_session_context with session "${session}" to inspect its result.`); - if (transition.orchestration) { - await this.setOrchestration(session, transition.orchestration); - } - } - - private _queueStatusChange(session: string, status: SessionStatus): void { - const previous = this._queues.get(session) ?? Promise.resolve(); - const next = previous.catch(() => undefined).then(() => this.handleStatusChange(session, status)); - this._queues.set(session, next); - void next.catch(error => { - this._logService.error(`[SessionCoordinationService] Failed to coordinate child session ${session}: ${toErrorMessage(error)}`); - }).finally(() => { - if (this._queues.get(session) === next) { - this._queues.delete(session); - } - }); - } - - private _startPrompt(creator: URI, prompt: string): void { - const chat = buildDefaultChatUri(creator); - const message: Message = { text: prompt, origin: { kind: MessageKind.SystemNotification } }; - if (this._stateManager.getActiveTurnId(chat)) { - this._stateManager.dispatchServerAction(chat, { - type: ActionType.ChatPendingMessageSet, - kind: PendingMessageKind.Queued, - id: generateUuid(), - message, - }); - return; - } - const action: ChatTurnStartedAction = { - type: ActionType.ChatTurnStarted, - turnId: generateUuid(), - startedAt: new Date().toISOString(), - message, - }; - this._stateManager.dispatchServerAction(chat, action); - this._delegate.handleAction(chat, action); - } -} diff --git a/src/vs/platform/agentHost/node/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts index df9a7fa8708..9d038c7aad4 100644 --- a/src/vs/platform/agentHost/node/sessionDatabase.ts +++ b/src/vs/platform/agentHost/node/sessionDatabase.ts @@ -135,6 +135,13 @@ export const sessionDatabaseMigrations: readonly ISessionDatabaseMigration[] = [ usage TEXT NOT NULL )`, }, + { + version: 10, + sql: `CREATE TABLE IF NOT EXISTS turn_delegation ( + turn_id TEXT PRIMARY KEY NOT NULL REFERENCES turns(id) ON DELETE CASCADE, + delegation TEXT NOT NULL + )`, + }, ]; // ---- Promise wrappers around callback-based @vscode/sqlite3 API ----------- @@ -436,6 +443,35 @@ export class SessionDatabase implements ISessionDatabase { }); } + setTurnDelegation(turnId: string, delegation: string): Promise { + return this._track(async () => { + const db = await this._ensureDb(); + await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]); + await dbRun(db, 'INSERT OR REPLACE INTO turn_delegation (turn_id, delegation) VALUES (?, ?)', [turnId, delegation]); + }); + } + + async getTurnDelegations(): Promise> { + await this.whenIdle(); + const db = await this._ensureDb(); + const rows = await dbAll( + db, + `SELECT d.turn_id AS turn_id, t.event_id AS event_id, d.delegation AS delegation + FROM turn_delegation d LEFT JOIN turns t ON t.id = d.turn_id`, + [], + ); + const result = new Map(); + for (const row of rows) { + const delegation = row.delegation as string; + result.set(row.turn_id as string, delegation); + const eventId = row.event_id as string | null; + if (eventId) { + result.set(eventId, delegation); + } + } + return result; + } + setTurnCheckpointRef(turnId: string, ref: string): Promise { return this._track(async () => { const db = await this._ensureDb(); @@ -836,6 +872,7 @@ export class SessionDatabase implements ISessionDatabase { // or the forked session would restore with no gauge and zero cost. for (const [oldId, newId] of mapping) { await dbRun(db, 'UPDATE turn_usage SET turn_id = ? WHERE turn_id = ?', [newId, oldId]); + await dbRun(db, 'UPDATE turn_delegation SET turn_id = ? WHERE turn_id = ?', [newId, oldId]); } await dbExec(db, 'COMMIT'); } catch (err) { diff --git a/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts b/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts index 23a5956e9e3..5e17585ece8 100644 --- a/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts +++ b/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts @@ -39,6 +39,7 @@ export interface IServerToolDisplay { export interface IServerToolExecutionContext { readonly sessionUri: URI; readonly chatUri: URI; + readonly turnId?: string; } /** @@ -187,6 +188,7 @@ export class AgentServerToolHost implements IAgentServerToolHost { return { sessionUri: parseRequiredSessionUriFromChatUri(chatUri), chatUri, + turnId: this._stateManager.getActiveTurnId(chatUri), }; } diff --git a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts index ed4cb35d9c5..5a53dbc047b 100644 --- a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts @@ -6,11 +6,12 @@ import type { Mutable } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { isEqual } from '../../../../base/common/resources.js'; +import type { IAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; import { localize } from '../../../../nls.js'; import { AgentSession, type AgentProvider, type IAgentCreateSessionConfig, type IAgentModelInfo, type IAgentSessionMetadata } from '../../common/agent.js'; import { SessionStatus } from '../../common/state/protocol/channels-session/state.js'; import type { IAgentServerToolDefinition } from '../../common/agentServerTools.js'; -import { buildChatUri, buildDefaultChatUri, getInlineToolInput, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionGitState, readSessionGitHubState, readSessionOrchestration, ResponsePartKind, ToolCallStatus, TurnState, type ISessionOrchestration, type Message, type ModelSelection, type ResponsePart, type SessionIdleNotification, type ToolCallState, type ToolDefinition, type Turn, type URI as ProtocolURI } from '../../common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, getInlineToolInput, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionGitState, readSessionGitHubState, ResponsePartKind, ToolCallStatus, TurnState, withSessionCreationReference, type Message, type ModelSelection, type ResponsePart, type ToolCallState, type ToolDefinition, type Turn, type URI as ProtocolURI } from '../../common/state/sessionState.js'; import { buildOpenSessionLinkUri, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../common/openSessionLink.js'; import { SessionServerToolName } from '../../common/serverToolNames.js'; import { generateUuid } from '../../../../base/common/uuid.js'; @@ -58,8 +59,6 @@ const listSessionsInputSchema: ToolDefinition['inputSchema'] = { includeArchived: { type: 'boolean', description: 'Whether to include archived sessions. Defaults to false; set true to also return archived sessions.' }, createdAfter: { type: 'string', description: 'Only return sessions created at or after this time (ISO-8601 timestamp, e.g. `2025-01-31T00:00:00Z`).' }, createdBefore: { type: 'string', description: 'Only return sessions created at or before this time (ISO-8601 timestamp).' }, - parentSession: { type: 'string', description: 'Only return sessions created by this parent session URI or open-session link.' }, - label: { type: 'string', description: 'Only return sessions with this orchestration label.' }, }, }; @@ -69,9 +68,6 @@ const createSessionInputSchema: ToolDefinition['inputSchema'] = { workspace: { type: 'string', description: 'Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session. Use `create_chat` instead when the work should share the current session\'s workspace and changes.' }, prompt: { type: 'string', description: 'Initial prompt to send to the new session.' }, model: { type: 'string', description: 'Optional model ID or display name. Defaults to the current chat\'s model.' }, - coordinateWithCreator: { type: 'boolean', description: 'Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true.' }, - notifyOnIdle: { type: 'string', enum: ['once', 'always'], description: 'Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle.' }, - label: { type: 'string', description: 'Optional label used to group and filter related child sessions.' }, }, required: ['workspace', 'prompt'], }; @@ -206,18 +202,12 @@ interface ICreateSessionArgs { readonly workspace?: unknown; readonly prompt?: unknown; readonly model?: unknown; - readonly coordinateWithCreator?: unknown; - readonly notifyOnIdle?: unknown; - readonly label?: unknown; } export interface IResolvedCreateSessionArgs { readonly workspace: URI; readonly prompt: string; readonly model?: IAgentModelInfo; - readonly coordinateWithCreator: boolean; - readonly notifyOnIdle?: SessionIdleNotification; - readonly label?: string; } /** Minimal dependency surface needed by the session server-tool group. */ @@ -228,7 +218,7 @@ export interface ISessionServerToolAccessor { readonly createSession: (config: IAgentCreateSessionConfig) => Promise; readonly getModels: () => readonly IAgentModelInfo[]; readonly getCreationDefaults: (source: URI) => ISessionCreationDefaults | undefined; - readonly startPrompt: (session: URI, chat: URI, prompt: string) => Promise; + readonly startPrompt: (session: URI, chat: URI, prompt: string, delegation?: IAgentMessageDelegationMeta) => Promise; readonly createChat: (session: URI, chat: URI, options?: { title?: string; model?: ModelSelection }) => Promise; readonly renameChat: (session: URI, chat: URI, title: string) => Promise; readonly reportToolError: (toolName: SessionServerToolName, error: unknown) => void; @@ -239,7 +229,6 @@ export interface ISessionServerToolAccessor { readonly getSessionSpawnDepth: (session: URI) => number; /** Records the spawn depth of a freshly-created session so its own `create_session` calls can enforce the recursion limit. */ readonly setSessionSpawnDepth: (session: URI, depth: number) => void; - readonly setSessionOrchestration: (session: URI, orchestration: ISessionOrchestration) => Promise; } export interface IRenameTitleResult { @@ -308,10 +297,6 @@ interface ISerializedSession { }[]; readonly git?: ISerializedGitState; readonly github?: ISerializedGitHubState; - readonly parentSession?: string; - readonly creator?: string; - readonly label?: string; - readonly notifyOnIdle?: SessionIdleNotification; } function getRequiredString(value: unknown, field: string, toolName: string): string { @@ -434,22 +419,10 @@ export function getCreateSessionArgs(rawArgs: unknown, sessions: readonly IAgent const workspace = getRequiredString(args.workspace, 'workspace', SessionServerToolName.CreateSession); const prompt = getRequiredString(args.prompt, 'prompt', SessionServerToolName.CreateSession); const modelName = getOptionalString(args.model, 'model', SessionServerToolName.CreateSession); - const coordinateWithCreator = getOptionalBoolean(args.coordinateWithCreator, 'coordinateWithCreator', SessionServerToolName.CreateSession) ?? true; - const label = getOptionalString(args.label, 'label', SessionServerToolName.CreateSession); - let notifyOnIdle: SessionIdleNotification | undefined; - if (args.notifyOnIdle !== undefined) { - if (args.notifyOnIdle !== 'once' && args.notifyOnIdle !== 'always') { - throw new Error(`Invalid ${SessionServerToolName.CreateSession} input: notifyOnIdle must be once or always.`); - } - notifyOnIdle = args.notifyOnIdle; - } return { workspace: resolveWorkspace(workspace, sessions), prompt, model: resolveModel(modelName, models), - coordinateWithCreator, - ...(notifyOnIdle !== undefined ? { notifyOnIdle } : {}), - ...(label !== undefined ? { label } : {}), }; } @@ -506,8 +479,6 @@ export interface IListSessionsArgs { readonly createdAfter?: number; /** Upper bound on session creation time, in epoch milliseconds. */ readonly createdBefore?: number; - readonly parentSession?: string; - readonly label?: string; } function getOptionalBoolean(value: unknown, field: string, toolName: string): boolean | undefined { @@ -536,7 +507,7 @@ function getOptionalTimestamp(value: unknown, field: string, toolName: string): /** Validates and normalizes the optional `list_sessions` filter arguments. */ export function getListSessionsArgs(rawArgs: unknown): IListSessionsArgs { - const args = (rawArgs ?? {}) as { session?: unknown; status?: unknown; workspace?: unknown; withChanges?: unknown; unread?: unknown; withPullRequest?: unknown; includeArchived?: unknown; createdAfter?: unknown; createdBefore?: unknown; parentSession?: unknown; label?: unknown }; + const args = (rawArgs ?? {}) as { session?: unknown; status?: unknown; workspace?: unknown; withChanges?: unknown; unread?: unknown; withPullRequest?: unknown; includeArchived?: unknown; createdAfter?: unknown; createdBefore?: unknown }; let status: Set | undefined; if (args.status !== undefined) { @@ -560,8 +531,6 @@ export function getListSessionsArgs(rawArgs: unknown): IListSessionsArgs { includeArchived: getOptionalBoolean(args.includeArchived, 'includeArchived', SessionServerToolName.ListSessions), createdAfter: getOptionalTimestamp(args.createdAfter, 'createdAfter', SessionServerToolName.ListSessions), createdBefore: getOptionalTimestamp(args.createdBefore, 'createdBefore', SessionServerToolName.ListSessions), - parentSession: getOptionalString(args.parentSession, 'parentSession', SessionServerToolName.ListSessions), - label: getOptionalString(args.label, 'label', SessionServerToolName.ListSessions), }; } @@ -595,33 +564,14 @@ function sessionMatchesWorkspace(session: IAgentSessionMetadata, workspace: stri } /** Applies the {@link IListSessionsArgs} filters to a set of sessions. */ -export function filterSessions(sessions: readonly IAgentSessionMetadata[], args: IListSessionsArgs, viewerSession?: string): readonly IAgentSessionMetadata[] { +export function filterSessions(sessions: readonly IAgentSessionMetadata[], args: IListSessionsArgs): readonly IAgentSessionMetadata[] { // A direct `session` lookup returns just that session, bypassing the other // filters (including the default archived exclusion). if (args.session !== undefined) { const target = parseOpenSessionLinkUri(args.session)?.toString() ?? args.session; return sessions.filter(session => session.session.toString() === target); } - const requestedParent = args.parentSession !== undefined - ? parseOpenSessionLinkUri(args.parentSession)?.toString() ?? args.parentSession - : undefined; - const viewerCanSeeRequestedParent = requestedParent === undefined || viewerSession === undefined || viewerSession === requestedParent - || sessions.some(session => { - const orchestration = readSessionOrchestration(session._meta); - return session.session.toString() === viewerSession - && orchestration?.parentSession === requestedParent - && orchestration.coordinateWithCreator; - }); return sessions.filter(session => { - const orchestration = readSessionOrchestration(session._meta); - if (requestedParent !== undefined) { - if (!viewerCanSeeRequestedParent || orchestration?.parentSession !== requestedParent) { - return false; - } - } - if (args.label !== undefined && orchestration?.label !== args.label) { - return false; - } if (args.status) { const names = describeSessionStatusNames(session); if (!names.some(name => args.status!.has(name))) { @@ -683,17 +633,10 @@ function serializeGitHubState(session: IAgentSessionMetadata): ISerializedGitHub return Object.keys(result).length > 0 ? result : undefined; } -function serializeSession(session: IAgentSessionMetadata, viewerSession?: string): ISerializedSession { +function serializeSession(session: IAgentSessionMetadata): ISerializedSession { const git = serializeGitState(session); const github = serializeGitHubState(session); const status = describeSessionStatus(session); - const orchestration = readSessionOrchestration(session._meta); - const canSeeParent = orchestration !== undefined && (viewerSession === undefined - || viewerSession === orchestration.parentSession - || (viewerSession === session.session.toString() && orchestration.coordinateWithCreator)); - const canSeeCreator = orchestration !== undefined && orchestration.coordinateWithCreator && (viewerSession === undefined - || viewerSession === orchestration.creatorSession - || viewerSession === session.session.toString()); return { session: session.session.toString(), openLink: buildOpenSessionLinkUri(session.session), @@ -720,18 +663,12 @@ function serializeSession(session: IAgentSessionMetadata, viewerSession?: string } : {}), ...(git !== undefined ? { git } : {}), ...(github !== undefined ? { github } : {}), - ...(orchestration !== undefined ? { - ...(canSeeParent ? { parentSession: orchestration.parentSession } : {}), - ...(canSeeCreator ? { creator: orchestration.creatorSession } : {}), - ...(orchestration.label !== undefined ? { label: orchestration.label } : {}), - ...(orchestration.notifyOnIdle !== undefined ? { notifyOnIdle: orchestration.notifyOnIdle } : {}), - } : {}), }; } /** Serializes session metadata into the compact tool-result JSON payload. */ -export function serializeSessions(sessions: readonly IAgentSessionMetadata[], viewerSession?: string): string { - return JSON.stringify({ sessions: sessions.map(session => serializeSession(session, viewerSession)) }); +export function serializeSessions(sessions: readonly IAgentSessionMetadata[]): string { + return JSON.stringify({ sessions: sessions.map(serializeSession) }); } export interface ICreateSessionResult { @@ -747,7 +684,7 @@ export interface ICreateSessionResult { * {@link currentSession} (the session the tool runs in) and stamps the new * session one level deeper so its own `create_session` calls are bounded too. */ -export async function applyCreateSessionTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, source?: URI): Promise { +export async function applyCreateSessionTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, source?: URI, sourceTurnId?: string): Promise { const currentSession = source ? currentSessionUri(source.toString()) : undefined; const parentDepth = currentSession ? accessor.getSessionSpawnDepth(currentSession) : 0; if (parentDepth >= maxSessionSpawnDepth) { @@ -763,20 +700,22 @@ export async function applyCreateSessionTool(accessor: ISessionServerToolAccesso ...(provider !== undefined ? { provider } : {}), ...(args.model !== undefined ? { model: { id: args.model.id } } : defaults?.model !== undefined ? { model: defaults.model } : {}), ...(inheritsSourceProvider && defaults?.config !== undefined ? { config: defaults.config } : {}), + ...(currentSession !== undefined && source !== undefined ? { + _meta: withSessionCreationReference(undefined, { + session: currentSession.toString(), + chat: source.toString(), + ...(sourceTurnId !== undefined ? { turnId: sourceTurnId } : {}), + }) + } : {}), }; const session = await accessor.createSession(config); accessor.setSessionSpawnDepth(session, parentDepth + 1); - if (currentSession) { - await accessor.setSessionOrchestration(session, { - parentSession: currentSession.toString(), - creatorSession: currentSession.toString(), - coordinateWithCreator: args.coordinateWithCreator, - ...(args.notifyOnIdle !== undefined ? { notifyOnIdle: args.notifyOnIdle } : {}), - ...(args.label !== undefined ? { label: args.label } : {}), - }); - } const chat = URI.parse(buildDefaultChatUri(session)); - await accessor.startPrompt(session, chat, args.prompt); + await accessor.startPrompt(session, chat, args.prompt, currentSession ? { + sourceSession: currentSession.toString(), + sourceChat: source?.toString(), + ...(sourceTurnId !== undefined ? { sourceTurnId } : {}), + } : undefined); return { session: session.toString(), chat: chat.toString(), openLink: buildOpenSessionLinkUri(session) }; } @@ -846,29 +785,22 @@ export function getCreateChatArgs(rawArgs: unknown, sessions: readonly IAgentSes return { session, prompt, ...(title !== undefined ? { title } : {}), ...(model !== undefined ? { model } : {}) }; } -function assertCanCoordinateWithTarget(sessions: readonly IAgentSessionMetadata[], source: URI, target: URI, toolName: SessionServerToolName): void { - const sourceMetadata = sessions.find(candidate => candidate.session.toString() === source.toString()); - const orchestration = readSessionOrchestration(sourceMetadata?._meta); - if (orchestration && !orchestration.coordinateWithCreator && orchestration.creatorSession === target.toString()) { - throw new Error(`Invalid ${toolName} input: this session is not allowed to coordinate with its creator.`); - } -} - /** Adds a chat to a session, sends its initial prompt, and returns the created channels. */ -export async function applyCreateChatTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, source?: URI): Promise { +export async function applyCreateChatTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, source?: URI, sourceTurnId?: string): Promise { const sessions = await accessor.listSessions(); const currentSession = source ? currentSessionUri(source.toString()) : undefined; const args = getCreateChatArgs(rawArgs, sessions, accessor.getModels(), currentSession); - if (currentSession) { - assertCanCoordinateWithTarget(sessions, currentSession, args.session, SessionServerToolName.CreateChat); - } const defaults = source ? accessor.getCreationDefaults(source) : undefined; const targetProvider = AgentSession.provider(args.session); const model = args.model !== undefined ? { id: args.model.id } : targetProvider === defaults?.provider ? defaults?.model : undefined; const chatId = generateUuid(); const chat = URI.parse(buildChatUri(args.session.toString(), chatId)); await accessor.createChat(args.session, chat, { title: args.title, model }); - await accessor.startPrompt(args.session, chat, args.prompt); + await accessor.startPrompt(args.session, chat, args.prompt, currentSession ? { + sourceSession: currentSession.toString(), + sourceChat: source?.toString(), + ...(sourceTurnId !== undefined ? { sourceTurnId } : {}), + } : undefined); return { session: args.session.toString(), chat: chat.toString(), openLink: buildOpenSessionLinkUri(args.session, chatId) }; } @@ -1037,17 +969,19 @@ export function getSendMessageArgs(rawArgs: unknown, sessions: readonly IAgentSe * Refuses to target {@link currentChannel} (the chat channel the tool runs on) * to avoid a session trivially messaging itself in a loop. */ -export async function applySendMessageTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, currentChannel?: ProtocolURI): Promise { +export async function applySendMessageTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, currentChannel?: ProtocolURI, sourceTurnId?: string): Promise { const sessions = await accessor.listSessions(); const { session, chat, chatId, message } = getSendMessageArgs(rawArgs, sessions); - if (currentChannel) { - const source = currentSessionUri(currentChannel); - assertCanCoordinateWithTarget(sessions, source, session, SessionServerToolName.SendMessage); - } if (currentChannel && chat.toString() === URI.parse(currentChannel).toString()) { throw new Error(`Invalid ${SessionServerToolName.SendMessage} input: refusing to send a message to the current chat.`); } - await accessor.startPrompt(session, chat, message); + const sourceChat = currentChannel ? URI.parse(currentChannel) : undefined; + const sourceSession = sourceChat ? currentSessionUri(sourceChat.toString()) : undefined; + await accessor.startPrompt(session, chat, message, sourceSession ? { + sourceSession: sourceSession.toString(), + sourceChat: sourceChat?.toString(), + ...(sourceTurnId !== undefined ? { sourceTurnId } : {}), + } : undefined); return formatSendMessageResult(buildOpenSessionLinkUri(session, chatId)); } @@ -1243,7 +1177,7 @@ export function serializeCurrentSession(currentSession: URI, sessions: readonly return JSON.stringify({ session: currentSession.toString(), openLink: buildOpenSessionLinkUri(currentSession), - ...(meta ? serializeSession(meta, currentSession.toString()) : {}), + ...(meta ? serializeSession(meta) : {}), }); } @@ -1359,8 +1293,7 @@ export function createSessionServerToolGroup(accessor?: ISessionServerToolAccess switch (toolName) { case SessionServerToolName.ListSessions: { - const viewerSession = currentSessionUri(currentChannel).toString(); - return serializeSessions(filterSessions(await accessor.listSessions(), getListSessionsArgs(rawArgs), viewerSession), viewerSession); + return serializeSessions(filterSessions(await accessor.listSessions(), getListSessionsArgs(rawArgs))); } case SessionServerToolName.GetCurrentSession: return serializeCurrentSession(currentSessionUri(currentChannel), await accessor.listSessions()); @@ -1368,7 +1301,7 @@ export function createSessionServerToolGroup(accessor?: ISessionServerToolAccess if (createdSessionCount >= maxCreatedSessions) { throw new Error(`Refusing to create more than ${maxCreatedSessions} sessions from server tools in this process.`); } - const result = await applyCreateSessionTool(accessor, rawArgs, URI.parse(currentChannel)); + const result = await applyCreateSessionTool(accessor, rawArgs, URI.parse(currentChannel), context.turnId); createdSessionCount++; return formatCreateSessionResult(result); } @@ -1376,7 +1309,7 @@ export function createSessionServerToolGroup(accessor?: ISessionServerToolAccess if (createdChatCount >= maxCreatedChats) { throw new Error(`Refusing to create more than ${maxCreatedChats} chats from server tools in this process.`); } - const result = await applyCreateChatTool(accessor, rawArgs, URI.parse(currentChannel)); + const result = await applyCreateChatTool(accessor, rawArgs, URI.parse(currentChannel), context.turnId); createdChatCount++; return formatCreateChatResult(result); } @@ -1386,7 +1319,7 @@ export function createSessionServerToolGroup(accessor?: ISessionServerToolAccess if (sentMessageCount >= maxSentMessages) { throw new Error(`Refusing to send more than ${maxSentMessages} messages from server tools in this process.`); } - const result = await applySendMessageTool(accessor, rawArgs, currentChannel); + const result = await applySendMessageTool(accessor, rawArgs, currentChannel, context.turnId); sentMessageCount++; return result; } diff --git a/src/vs/platform/agentHost/test/common/openSessionLink.test.ts b/src/vs/platform/agentHost/test/common/openSessionLink.test.ts index 430b75d80de..18a792b5ca8 100644 --- a/src/vs/platform/agentHost/test/common/openSessionLink.test.ts +++ b/src/vs/platform/agentHost/test/common/openSessionLink.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { buildOpenSessionLinkForChatResource, buildOpenSessionLinkUri, createAgentSessionLinkPresentation, isCreateChatTool, isCreateSessionTool, isSendMessageTool, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../common/openSessionLink.js'; +import { buildOpenSessionLinkForChatResource, buildOpenSessionLinkUri, createAgentSessionLinkPresentation, isCreateChatTool, isCreateSessionTool, isSendMessageTool, parseOpenSessionLinkChatId, parseOpenSessionLinkTurnId, parseOpenSessionLinkUri } from '../../common/openSessionLink.js'; import { buildChatUri, buildDefaultChatUri } from '../../common/state/sessionState.js'; suite('openSessionLink', () => { @@ -49,6 +49,13 @@ suite('openSessionLink', () => { assert.strictEqual(parseOpenSessionLinkChatId(buildOpenSessionLinkUri('copilotcli:/abc-123')), undefined); }); + test('carries an optional chat and turn id', () => { + const link = buildOpenSessionLinkUri('copilotcli:/abc-123', 'chat-9', 'turn-7'); + assert.strictEqual(link, 'agent-host-session://copilotcli/abc-123?chat=chat-9&turn=turn-7'); + assert.strictEqual(parseOpenSessionLinkChatId(link), 'chat-9'); + assert.strictEqual(parseOpenSessionLinkTurnId(link), 'turn-7'); + }); + test('normalizes the default chat id to a session-only link', () => { assert.strictEqual(buildOpenSessionLinkUri('copilotcli:/abc-123', 'default'), 'agent-host-session://copilotcli/abc-123'); }); diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index 2f593da25bd..c757e4a3e28 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -20,6 +20,8 @@ export class TestSessionDatabase implements ISessionDatabase { private readonly _reviewedFiles: IReviewedFileRecord[] = []; private readonly _localTurns = new Map(); private readonly _turnUsages = new Map(); + private readonly _turnDelegations = new Map(); + private readonly _turnEventIds = new Map(); getAllFileEditsCalls = 0; getFileEditsByTurnCalls = 0; @@ -35,6 +37,8 @@ export class TestSessionDatabase implements ISessionDatabase { async createTurn(): Promise { } async deleteTurn(turnId: string): Promise { + this._turnDelegations.delete(turnId); + this._turnEventIds.delete(turnId); for (let i = this._edits.length - 1; i >= 0; i--) { if (this._edits[i].turnId === turnId) { this._edits.splice(i, 1); @@ -129,9 +133,12 @@ export class TestSessionDatabase implements ISessionDatabase { async setTurnEventId(turnId: string, eventId: string): Promise { this.setTurnEventIdCalls.push({ turnId, eventId }); + this._turnEventIds.set(turnId, eventId); } - async getTurnEventId(_turnId: string): Promise { return undefined; } + async getTurnEventId(turnId: string): Promise { + return this._turnEventIds.get(turnId) ?? [...this._turnEventIds].find(([, eventId]) => eventId === turnId)?.[1]; + } async getNextTurnEventId(_turnId: string): Promise { return undefined; } @@ -143,6 +150,21 @@ export class TestSessionDatabase implements ISessionDatabase { async getTurnUsages(): Promise> { return new Map(this._turnUsages); } + async setTurnDelegation(turnId: string, delegation: string): Promise { + this._turnDelegations.set(turnId, delegation); + } + + async getTurnDelegations(): Promise> { + const result = new Map(this._turnDelegations); + for (const [turnId, eventId] of this._turnEventIds) { + const delegation = this._turnDelegations.get(turnId); + if (delegation) { + result.set(eventId, delegation); + } + } + return result; + } + async truncateFromTurn(_turnId: string): Promise { } async deleteTurnsAfter(turnId: string): Promise { @@ -152,6 +174,8 @@ export class TestSessionDatabase implements ISessionDatabase { async deleteAllTurns(): Promise { this.deleteAllTurnsCalls++; this._edits.length = 0; + this._turnDelegations.clear(); + this._turnEventIds.clear(); } async insertLocalTurn(record: ILocalTurnRecord): Promise { @@ -167,7 +191,25 @@ export class TestSessionDatabase implements ISessionDatabase { this._localTurns.delete(id); } } - async remapTurnIds(_mapping: ReadonlyMap): Promise { } + async remapTurnIds(mapping: ReadonlyMap, eventIds?: ReadonlyMap): Promise { + for (const turnId of [...this._turnDelegations.keys()]) { + if (!mapping.has(turnId)) { + this._turnDelegations.delete(turnId); + } + } + for (const [oldId, newId] of mapping) { + const delegation = this._turnDelegations.get(oldId); + if (delegation) { + this._turnDelegations.delete(oldId); + this._turnDelegations.set(newId, delegation); + } + const eventId = eventIds?.get(newId) ?? this._turnEventIds.get(oldId); + this._turnEventIds.delete(oldId); + if (eventId) { + this._turnEventIds.set(newId, eventId); + } + } + } async markFileReviewed(uri: URI, nonce: string): Promise { if (!this._reviewedFiles.some(r => r.uri.toString() === uri.toString() && r.nonce === nonce)) { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 080b3aa5364..d77a4bd386d 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -41,9 +41,10 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentMergeConfigKey, readAgentMergeSessionState } from '../../common/agentMerge.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope, NotificationType, type INotification } from '../../common/state/sessionActions.js'; -import { AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_ORCHESTRATION_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isDefaultChatUri, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionOrchestration, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionOrchestration, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; +import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isDefaultChatUri, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionCreationReference, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; import { ChatInteractivity, type MessageAttachment } from '../../common/state/protocol/state.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; +import { readAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; import { AgentHostDatabase, IAgentHostDatabase, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionOptions } from '../../node/agentHostDatabase.js'; @@ -7117,88 +7118,23 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual(readSessionMultiRootMetadata(getStateManager(localService).getSessionState(sessionResource.toString())?._meta), multiRoot); }); - test('restores persisted orchestration metadata', async () => { + test('restores persisted session creation metadata', async () => { const db = new TestSessionDatabase(); const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(copilotAgent); await createAgentSession(copilotAgent); const sessionResource = (await copilotAgent.listSessions())[0].session; copilotAgent.sessionMessages = []; - const orchestration = { - parentSession: 'copilot:/parent', - creatorSession: 'copilot:/creator', - coordinateWithCreator: true, - notifyOnIdle: 'always', - } as const; - await db.setMetadata(AH_META_ORCHESTRATION_DB_KEY, JSON.stringify(orchestration)); + const creationReference = { + session: 'copilot:/creator', + chat: buildDefaultChatUri('copilot:/creator'), + turnId: 'turn-1', + }; + await db.setMetadata(AH_META_CREATED_BY_SESSION_DB_KEY, JSON.stringify(creationReference)); await localService.restoreSession(sessionResource); - assert.deepStrictEqual(readSessionOrchestration(getStateManager(localService).getSessionState(sessionResource.toString())?._meta), orchestration); - }); - - test('does not consume a child notification when its creator cannot be resolved', async () => { - const sessionData = createPerSessionDataService(); - const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); - localService.registerProvider(copilotAgent); - const child = await localService.createSession({ provider: 'copilot' }); - const orchestration: ISessionOrchestration = { - parentSession: 'copilot:/missing', - creatorSession: 'copilot:/missing', - coordinateWithCreator: true, - notifyOnIdle: 'once', - creatorNotificationState: 'waitingForCompletion', - }; - const coordinator = localService as unknown as { - _sessionCoordination: { - setOrchestration(session: string, value: ISessionOrchestration): Promise; - handleStatusChange(session: string, status: SessionStatus): Promise; - }; - }; - await coordinator._sessionCoordination.setOrchestration(child.toString(), orchestration); - - await coordinator._sessionCoordination.handleStatusChange(child.toString(), SessionStatus.Idle); - - assert.deepStrictEqual(readSessionOrchestration(getStateManager(localService).getSessionSummary(child.toString())?._meta), orchestration); - }); - - test('restores a cold creator before delivering and consuming a child notification', async () => { - const sessionData = createPerSessionDataService(); - const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); - localService.registerProvider(copilotAgent); - const creator = await localService.createSession({ provider: 'copilot' }); - const child = await localService.createSession({ provider: 'copilot' }); - const orchestration: ISessionOrchestration = { - parentSession: creator.toString(), - creatorSession: creator.toString(), - coordinateWithCreator: true, - notifyOnIdle: 'once', - creatorNotificationState: 'waitingForCompletion', - }; - const coordinator = localService as unknown as { - _sessionCoordination: { - setOrchestration(session: string, value: ISessionOrchestration): Promise; - handleStatusChange(session: string, status: SessionStatus): Promise; - }; - }; - await coordinator._sessionCoordination.setOrchestration(child.toString(), orchestration); - getStateManager(localService).removeSession(creator.toString()); - assert.strictEqual(getStateManager(localService).getSessionState(creator.toString()), undefined); - let notificationStarted = false; - disposables.add(getStateManager(localService).onDidEmitEnvelope(envelope => { - if (envelope.channel === buildDefaultChatUri(creator) && envelope.action.type === ActionType.ChatTurnStarted && envelope.action.message.origin.kind === MessageKind.SystemNotification) { - notificationStarted = true; - } - })); - - await coordinator._sessionCoordination.handleStatusChange(child.toString(), SessionStatus.Idle); - - assert.ok(getStateManager(localService).getSessionState(creator.toString())); - assert.strictEqual(notificationStarted, true); - assert.deepStrictEqual(readSessionOrchestration(getStateManager(localService).getSessionSummary(child.toString())?._meta), { - ...orchestration, - creatorNotificationState: 'notified', - }); + assert.deepStrictEqual(readSessionCreationReference(getStateManager(localService).getSessionState(sessionResource.toString())?._meta), creationReference); }); test('restores persisted source-control provenance', async () => { @@ -10781,17 +10717,23 @@ suite('AgentService (node dispatcher)', () => { message: { text: 'Create more work', origin: { kind: MessageKind.User }, model: { id: 'source-model' } }, }, 'test-client', 1); const sourceModelBeforeCreation = getStateManager(localService).getSessionState(sourceChat.toString())?.activeTurn?.message.model; + const sessionUrisBeforeCreation = new Set(getStateManager(localService).getSessionUris()); await agent.serverToolHost!.executeTool(sourceChat.toString(), SessionServerToolName.CreateSession, { workspace: URI.file('/workspace').toString(), prompt: 'new session', }); + const createdSessionUri = getStateManager(localService).getSessionUris().find(uri => !sessionUrisBeforeCreation.has(uri)); + const delegatedMessage = createdSessionUri + ? getStateManager(localService).getChatState(buildDefaultChatUri(createdSessionUri))?.activeTurn?.message + : undefined; await agent.serverToolHost!.executeTool(sourceChat.toString(), SessionServerToolName.CreateChat, { prompt: 'new chat', }); assert.deepStrictEqual({ sourceModelBeforeCreation, + delegation: delegatedMessage && readAgentMessageDelegationMeta(delegatedMessage), sessionConfig: { ...agent.createSessionConfigs.at(-1), session: agent.createSessionConfigs.at(-1)?.session?.scheme, @@ -10800,6 +10742,11 @@ suite('AgentService (node dispatcher)', () => { chatOptions: agent.createChatOptions.at(-1), }, { sourceModelBeforeCreation: { id: 'source-model' }, + delegation: { + sourceSession: sourceSession.toString(), + sourceChat: sourceChat.toString(), + sourceTurnId: 'source-turn', + }, sessionConfig: { session: 'copilot', model: { id: 'source-model' }, diff --git a/src/vs/platform/agentHost/test/node/chatContributions.test.ts b/src/vs/platform/agentHost/test/node/chatContributions.test.ts index 11969dadcd2..06dc13ca4fb 100644 --- a/src/vs/platform/agentHost/test/node/chatContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/chatContributions.test.ts @@ -21,6 +21,7 @@ import { AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext } fro import { createChatMementoKey, createSessionMementoKey, IAgentHostChatContributions, type IAgentHostChatContribution, type IAgentHostChatContributionContext, type IAgentHostChatContributionHost, type IHydrationContext, type IObservedAction, type IOutgoingTurn, type ITurnEnd } from '../../common/agentHostChatContributionsService.js'; import { AgentHostArtifactToolsConfigKey, AgentHostMarkdownPlanRichLinksEnabledConfigKey, type ISchema, type SchemaDefinition, type SchemaValue } from '../../common/agentHostSchema.js'; import { withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; +import { readAgentMessageDelegationMeta, toAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { ChatOriginKind } from '../../common/state/protocol/state.js'; @@ -41,6 +42,7 @@ import { registerBuiltInChatContributions } from '../../node/chatContributions/b import { QueueDrainContribution } from '../../node/chatContributions/queueDrain/queueDrainContribution.js'; import { SessionTitleContribution } from '../../node/chatContributions/sessionTitle/sessionTitleContribution.js'; import { SideChatContribution } from '../../node/chatContributions/sideChat/sideChatContribution.js'; +import { TurnDelegationContribution } from '../../node/chatContributions/turnDelegation/turnDelegationContribution.js'; import { injectSideChatContext } from '../../node/chatContributions/sideChat/sideChatContext.js'; import { ARTIFACT_TOOLS_INSTRUCTION } from '../../node/shared/artifactServerTools.js'; import { AGENT_HOST_TITLE_SOURCE_USER, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from '../../node/shared/persistSessionMetadata.js'; @@ -594,6 +596,21 @@ function createSessionTitleContributions(disposables: ReturnType) { + const logService = new NullLogService(); + const database = new TestSessionDatabase(); + const sessionDataService = createSessionDataService(database); + const services = new ServiceCollection( + [ILogService, logService], + [ISessionDataService, sessionDataService], + ); + const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); + const service: IAgentHostChatContributions = disposables.add(new AgentHostChatContributions(logService, instantiationService)); + disposables.add(service.registerContribution(TurnDelegationContribution)); + const session = 'copilot:/target'; + return { service, database, session, chat: buildDefaultChatUri(session) }; +} + function createBuiltInContributions(disposables: ReturnType, observed?: string[], enableSendInstructions = false): { readonly service: AgentHostChatContributions; readonly stateManager: AgentHostStateManager; readonly session: string } { const logService = new NullLogService(); const stateManager = disposables.add(new AgentHostStateManager(logService)); @@ -1162,6 +1179,45 @@ suite('AgentHostChatContributions', () => { assert.deepStrictEqual(turns.map(turn => [turn.id, turn.message.text]), [['built-in-hydration-order', 'side question']]); }); + test('persists and restores agent-authored turn delegation through a provider turn id', async () => { + const contributions = createTurnDelegationContributions(disposables); + const delegation = { + sourceSession: 'copilot:/source', + sourceChat: buildDefaultChatUri('copilot:/source'), + sourceTurnId: 'source-turn', + }; + await contributions.service.outgoingTurn({ + session: contributions.session, + chat: contributions.chat, + turnId: 'host-turn', + message: { + text: 'delegated prompt', + origin: { kind: MessageKind.Agent }, + _meta: toAgentMessageDelegationMeta(delegation), + }, + }); + const [directlyRestored] = await contributions.service.hydrateTurns( + { session: contributions.session, chat: contributions.chat }, + [hydrationTurn('host-turn')], + ); + await contributions.database.setTurnEventId('host-turn', 'provider-turn'); + const [providerRestored] = await contributions.service.hydrateTurns( + { session: contributions.session, chat: contributions.chat }, + [hydrationTurn('provider-turn')], + ); + + assert.deepStrictEqual( + [directlyRestored, providerRestored].map(turn => ({ + origin: turn.message.origin, + delegation: readAgentMessageDelegationMeta(turn.message), + })), + [ + { origin: { kind: MessageKind.Agent }, delegation }, + { origin: { kind: MessageKind.Agent }, delegation }, + ], + ); + }); + test('isolates a throwing contribution', () => { const contributions = disposables.add(createContributions(disposables, ThrowingContribution, FollowingContribution)); contributions.turnEnd(turnEnd('throwing')); diff --git a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts index 5be0f26acff..df5cdb3d21f 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts @@ -1440,6 +1440,42 @@ suite('CodexAgent chat backing durability', () => { }); }); + test('persists the app-server turn id for restored turn metadata', async () => { + const sessionStore = createTestSessionStore(); + const session = AgentSession.uri('codex', 'turn-id-mapping'); + const chat = URI.parse(buildDefaultChatUri(session)); + const folder = URI.file('/repo/turn-id-mapping'); + const agent = await createAgent(disposables, { sdkResolvableWithoutDownload: true, sessionStore }); + const peer = disposables.add(createTestPeer()); + connect(agent, peer); + + try { + await materializeSession(agent, peer, session, chat, folder, 'codex-thread'); + const codexSession = agent['_sessions'].get(AgentSession.id(session))!; + agent['_handleTurnStartedNotification'](codexSession, { + threadId: 'codex-thread', + turn: { + id: 'app-turn-1', + items: [], + itemsView: 'full', + status: 'inProgress', + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }, + }); + await new Promise(resolve => setImmediate(resolve)); + + assert.deepStrictEqual(sessionStore.databaseFor(session).setTurnEventIdCalls, [{ + turnId: 'turn-1', + eventId: 'app-turn-1', + }]); + } finally { + peer.dispose(); + } + }); + test('the materialize receipt re-keys the chat backing onto the runtime, so a restored session stays addressable', async () => { const sessionStore = createTestSessionStore(); const session = AgentSession.uri('codex', 'host-session'); diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md index 5dd0011af97..b5fa4c04b80 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md @@ -719,14 +719,6 @@ "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." - }, - "parentSession": { - "type": "string", - "description": "Only return sessions created by this parent session URI or open-session link." - }, - "label": { - "type": "string", - "description": "Only return sessions with this orchestration label." } } } @@ -756,22 +748,6 @@ "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." - }, - "coordinateWithCreator": { - "type": "boolean", - "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." - }, - "notifyOnIdle": { - "type": "string", - "enum": [ - "once", - "always" - ], - "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." - }, - "label": { - "type": "string", - "description": "Optional label used to group and filter related child sessions." } }, "required": [ diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md index e9a8448c64b..a3758593e86 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md @@ -719,14 +719,6 @@ "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." - }, - "parentSession": { - "type": "string", - "description": "Only return sessions created by this parent session URI or open-session link." - }, - "label": { - "type": "string", - "description": "Only return sessions with this orchestration label." } } } @@ -756,22 +748,6 @@ "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." - }, - "coordinateWithCreator": { - "type": "boolean", - "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." - }, - "notifyOnIdle": { - "type": "string", - "enum": [ - "once", - "always" - ], - "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." - }, - "label": { - "type": "string", - "description": "Optional label used to group and filter related child sessions." } }, "required": [ diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md index 6a1b0e8d177..cf078bf44ff 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md @@ -719,14 +719,6 @@ "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." - }, - "parentSession": { - "type": "string", - "description": "Only return sessions created by this parent session URI or open-session link." - }, - "label": { - "type": "string", - "description": "Only return sessions with this orchestration label." } } } @@ -756,22 +748,6 @@ "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." - }, - "coordinateWithCreator": { - "type": "boolean", - "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." - }, - "notifyOnIdle": { - "type": "string", - "enum": [ - "once", - "always" - ], - "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." - }, - "label": { - "type": "string", - "description": "Optional label used to group and filter related child sessions." } }, "required": [ diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md index 8116af35f25..6489d04ff7a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md @@ -719,14 +719,6 @@ "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." - }, - "parentSession": { - "type": "string", - "description": "Only return sessions created by this parent session URI or open-session link." - }, - "label": { - "type": "string", - "description": "Only return sessions with this orchestration label." } } } @@ -756,22 +748,6 @@ "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." - }, - "coordinateWithCreator": { - "type": "boolean", - "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." - }, - "notifyOnIdle": { - "type": "string", - "enum": [ - "once", - "always" - ], - "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." - }, - "label": { - "type": "string", - "description": "Optional label used to group and filter related child sessions." } }, "required": [ diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_8.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_8.prompt.md index 5adbb7dbaf1..9a28b8ab25e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_8.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_8.prompt.md @@ -719,14 +719,6 @@ "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." - }, - "parentSession": { - "type": "string", - "description": "Only return sessions created by this parent session URI or open-session link." - }, - "label": { - "type": "string", - "description": "Only return sessions with this orchestration label." } } } @@ -756,22 +748,6 @@ "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." - }, - "coordinateWithCreator": { - "type": "boolean", - "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." - }, - "notifyOnIdle": { - "type": "string", - "enum": [ - "once", - "always" - ], - "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." - }, - "label": { - "type": "string", - "description": "Optional label used to group and filter related child sessions." } }, "required": [ diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md index 967367862f2..ad621ade36f 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md @@ -719,14 +719,6 @@ "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." - }, - "parentSession": { - "type": "string", - "description": "Only return sessions created by this parent session URI or open-session link." - }, - "label": { - "type": "string", - "description": "Only return sessions with this orchestration label." } } } @@ -756,22 +748,6 @@ "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." - }, - "coordinateWithCreator": { - "type": "boolean", - "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." - }, - "notifyOnIdle": { - "type": "string", - "enum": [ - "once", - "always" - ], - "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." - }, - "label": { - "type": "string", - "description": "Optional label used to group and filter related child sessions." } }, "required": [ diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md index e91fce1b609..66cb61bd82c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md @@ -719,14 +719,6 @@ "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." - }, - "parentSession": { - "type": "string", - "description": "Only return sessions created by this parent session URI or open-session link." - }, - "label": { - "type": "string", - "description": "Only return sessions with this orchestration label." } } } @@ -756,22 +748,6 @@ "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." - }, - "coordinateWithCreator": { - "type": "boolean", - "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." - }, - "notifyOnIdle": { - "type": "string", - "enum": [ - "once", - "always" - ], - "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." - }, - "label": { - "type": "string", - "description": "Optional label used to group and filter related child sessions." } }, "required": [ diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md index 84812364b9e..e8ec9e071e6 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md @@ -719,14 +719,6 @@ "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." - }, - "parentSession": { - "type": "string", - "description": "Only return sessions created by this parent session URI or open-session link." - }, - "label": { - "type": "string", - "description": "Only return sessions with this orchestration label." } } } @@ -756,22 +748,6 @@ "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." - }, - "coordinateWithCreator": { - "type": "boolean", - "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." - }, - "notifyOnIdle": { - "type": "string", - "enum": [ - "once", - "always" - ], - "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." - }, - "label": { - "type": "string", - "description": "Optional label used to group and filter related child sessions." } }, "required": [ diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md index ad2e28d80fd..1c1b6f29d24 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md @@ -719,14 +719,6 @@ "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." - }, - "parentSession": { - "type": "string", - "description": "Only return sessions created by this parent session URI or open-session link." - }, - "label": { - "type": "string", - "description": "Only return sessions with this orchestration label." } } } @@ -756,22 +748,6 @@ "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." - }, - "coordinateWithCreator": { - "type": "boolean", - "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." - }, - "notifyOnIdle": { - "type": "string", - "enum": [ - "once", - "always" - ], - "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." - }, - "label": { - "type": "string", - "description": "Optional label used to group and filter related child sessions." } }, "required": [ diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md index ea115ca49be..778218d6a72 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md @@ -747,14 +747,6 @@ "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." - }, - "parentSession": { - "type": "string", - "description": "Only return sessions created by this parent session URI or open-session link." - }, - "label": { - "type": "string", - "description": "Only return sessions with this orchestration label." } } }, @@ -788,22 +780,6 @@ "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." - }, - "coordinateWithCreator": { - "type": "boolean", - "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." - }, - "notifyOnIdle": { - "type": "string", - "enum": [ - "once", - "always" - ], - "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." - }, - "label": { - "type": "string", - "description": "Optional label used to group and filter related child sessions." } }, "required": [ diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md index 195d1758275..d6e442f814c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md @@ -708,14 +708,6 @@ "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." - }, - "parentSession": { - "type": "string", - "description": "Only return sessions created by this parent session URI or open-session link." - }, - "label": { - "type": "string", - "description": "Only return sessions with this orchestration label." } } }, @@ -749,22 +741,6 @@ "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." - }, - "coordinateWithCreator": { - "type": "boolean", - "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." - }, - "notifyOnIdle": { - "type": "string", - "enum": [ - "once", - "always" - ], - "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." - }, - "label": { - "type": "string", - "description": "Optional label used to group and filter related child sessions." } }, "required": [ diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md index 5fcfd424643..ec8a51f4b0c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md @@ -747,14 +747,6 @@ "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." - }, - "parentSession": { - "type": "string", - "description": "Only return sessions created by this parent session URI or open-session link." - }, - "label": { - "type": "string", - "description": "Only return sessions with this orchestration label." } } }, @@ -788,22 +780,6 @@ "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." - }, - "coordinateWithCreator": { - "type": "boolean", - "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." - }, - "notifyOnIdle": { - "type": "string", - "enum": [ - "once", - "always" - ], - "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." - }, - "label": { - "type": "string", - "description": "Optional label used to group and filter related child sessions." } }, "required": [ diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md index 951879c2c22..cd6f2ec16b1 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md @@ -747,14 +747,6 @@ "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." - }, - "parentSession": { - "type": "string", - "description": "Only return sessions created by this parent session URI or open-session link." - }, - "label": { - "type": "string", - "description": "Only return sessions with this orchestration label." } } }, @@ -788,22 +780,6 @@ "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." - }, - "coordinateWithCreator": { - "type": "boolean", - "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." - }, - "notifyOnIdle": { - "type": "string", - "enum": [ - "once", - "always" - ], - "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." - }, - "label": { - "type": "string", - "description": "Optional label used to group and filter related child sessions." } }, "required": [ diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md index 091e1b8c239..b0f6c1a5e0e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md @@ -708,14 +708,6 @@ "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." - }, - "parentSession": { - "type": "string", - "description": "Only return sessions created by this parent session URI or open-session link." - }, - "label": { - "type": "string", - "description": "Only return sessions with this orchestration label." } } }, @@ -749,22 +741,6 @@ "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." - }, - "coordinateWithCreator": { - "type": "boolean", - "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." - }, - "notifyOnIdle": { - "type": "string", - "enum": [ - "once", - "always" - ], - "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." - }, - "label": { - "type": "string", - "description": "Optional label used to group and filter related child sessions." } }, "required": [ diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md index f393d6a545b..2d1b223908b 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md @@ -708,14 +708,6 @@ "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." - }, - "parentSession": { - "type": "string", - "description": "Only return sessions created by this parent session URI or open-session link." - }, - "label": { - "type": "string", - "description": "Only return sessions with this orchestration label." } } }, @@ -749,22 +741,6 @@ "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." - }, - "coordinateWithCreator": { - "type": "boolean", - "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." - }, - "notifyOnIdle": { - "type": "string", - "enum": [ - "once", - "always" - ], - "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." - }, - "label": { - "type": "string", - "description": "Optional label used to group and filter related child sessions." } }, "required": [ diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md index 9096527cc05..e5779d57841 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md @@ -747,14 +747,6 @@ "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." - }, - "parentSession": { - "type": "string", - "description": "Only return sessions created by this parent session URI or open-session link." - }, - "label": { - "type": "string", - "description": "Only return sessions with this orchestration label." } } }, @@ -788,22 +780,6 @@ "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." - }, - "coordinateWithCreator": { - "type": "boolean", - "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." - }, - "notifyOnIdle": { - "type": "string", - "enum": [ - "once", - "always" - ], - "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." - }, - "label": { - "type": "string", - "description": "Optional label used to group and filter related child sessions." } }, "required": [ diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md index 77534b703b6..d71f7e47ca8 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md @@ -708,14 +708,6 @@ "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." - }, - "parentSession": { - "type": "string", - "description": "Only return sessions created by this parent session URI or open-session link." - }, - "label": { - "type": "string", - "description": "Only return sessions with this orchestration label." } } }, @@ -749,22 +741,6 @@ "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." - }, - "coordinateWithCreator": { - "type": "boolean", - "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." - }, - "notifyOnIdle": { - "type": "string", - "enum": [ - "once", - "always" - ], - "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." - }, - "label": { - "type": "string", - "description": "Optional label used to group and filter related child sessions." } }, "required": [ diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md index cc4f00ae4d9..9af4c590a33 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md @@ -708,14 +708,6 @@ "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." - }, - "parentSession": { - "type": "string", - "description": "Only return sessions created by this parent session URI or open-session link." - }, - "label": { - "type": "string", - "description": "Only return sessions with this orchestration label." } } }, @@ -749,22 +741,6 @@ "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." - }, - "coordinateWithCreator": { - "type": "boolean", - "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." - }, - "notifyOnIdle": { - "type": "string", - "enum": [ - "once", - "always" - ], - "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." - }, - "label": { - "type": "string", - "description": "Optional label used to group and filter related child sessions." } }, "required": [ diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md index f0e9028a456..7bfa5e79ce2 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md @@ -708,14 +708,6 @@ "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." - }, - "parentSession": { - "type": "string", - "description": "Only return sessions created by this parent session URI or open-session link." - }, - "label": { - "type": "string", - "description": "Only return sessions with this orchestration label." } } }, @@ -749,22 +741,6 @@ "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." - }, - "coordinateWithCreator": { - "type": "boolean", - "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." - }, - "notifyOnIdle": { - "type": "string", - "enum": [ - "once", - "always" - ], - "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." - }, - "label": { - "type": "string", - "description": "Optional label used to group and filter related child sessions." } }, "required": [ diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts index 677ee91138f..9a6c4204330 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts @@ -18,7 +18,7 @@ import type { ListSessionsResult, SubscribeResult } from '../../../../common/sta import { ActionType, NotificationType, type ChatToolCallCompleteAction, type ChatToolCallStartAction, type SessionAddedParams, type StateAction } from '../../../../common/state/sessionActions.js'; import { buildDefaultChatUri, - readSessionOrchestration, + readSessionCreationReference, ROOT_STATE_URI, type AnnotationsState, type ChatState, @@ -866,8 +866,8 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void }, 30_000); const child = (childAdded.params as SessionAddedParams).summary; createdSessions.push(child.resource); - const orchestration = readSessionOrchestration(child._meta); - assert.ok(orchestration, 'child SessionAdded summary should include orchestration metadata'); + const creationReference = readSessionCreationReference(child._meta); + assert.ok(creationReference, 'child SessionAdded summary should include its creating turn'); const childRequest = await retry(async () => { const requests = context.observedModelRequestBodies .map(summarizeAnthropicRequest) @@ -884,16 +884,16 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void provider: child.provider, messages: childState.turns.map(turn => turn.message.text), childRequestModel: childRequest.model, - orchestration, + creationReference, }, { sawPendingConfirmation: true, provider: model.provider, messages: [childPrompt], childRequestModel: model.id, - orchestration: { - parentSession: session.sessionUri, - creatorSession: session.sessionUri, - coordinateWithCreator: true, + creationReference: { + session: session.sessionUri, + chat: session.chatUri, + turnId: 'turn-create-session', }, }); }, supportsProviderModelSessionCreation); diff --git a/src/vs/platform/agentHost/test/node/sessionCoordination.test.ts b/src/vs/platform/agentHost/test/node/sessionCoordination.test.ts deleted file mode 100644 index df649abdd84..00000000000 --- a/src/vs/platform/agentHost/test/node/sessionCoordination.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { SessionStatus, type ISessionOrchestration } from '../../common/state/sessionState.js'; -import { transitionSessionCoordination } from '../../node/sessionCoordination.js'; - -suite('SessionCoordination', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - const base: ISessionOrchestration = { - parentSession: 'copilot:/parent', - creatorSession: 'copilot:/creator', - coordinateWithCreator: true, - notifyOnIdle: 'once', - }; - - test('waits for completion only after work starts', () => { - assert.deepStrictEqual(transitionSessionCoordination(SessionStatus.Idle, base), { notify: false }); - assert.deepStrictEqual(transitionSessionCoordination(SessionStatus.InProgress, base), { - orchestration: { ...base, creatorNotificationState: 'waitingForCompletion' }, - notify: false, - }); - }); - - test('notifies once after idle or error', () => { - const waiting = { ...base, creatorNotificationState: 'waitingForCompletion' as const }; - const expected = { - orchestration: { ...waiting, creatorNotificationState: 'notified' as const }, - notify: true, - }; - assert.deepStrictEqual(transitionSessionCoordination(SessionStatus.Idle, waiting), expected); - assert.deepStrictEqual(transitionSessionCoordination(SessionStatus.Error, waiting), expected); - assert.deepStrictEqual(transitionSessionCoordination(SessionStatus.InProgress, expected.orchestration), { notify: false }); - }); - - test('notifies once when input is needed and deduplicates repeated status', () => { - const waiting = { ...base, creatorNotificationState: 'waitingForCompletion' as const }; - const transition = transitionSessionCoordination(SessionStatus.InputNeeded, waiting); - assert.deepStrictEqual(transition, { - orchestration: { ...waiting, creatorNotificationState: 'notified' }, - notify: true, - }); - assert.deepStrictEqual(transitionSessionCoordination(SessionStatus.InputNeeded, transition.orchestration!), { notify: false }); - }); - - test('always waits for later work to complete', () => { - const always: ISessionOrchestration = { ...base, notifyOnIdle: 'always', creatorNotificationState: 'notified' }; - assert.deepStrictEqual(transitionSessionCoordination(SessionStatus.InProgress, always), { - orchestration: { ...always, creatorNotificationState: 'waitingForCompletion' }, - notify: false, - }); - }); - - test('always captures back-to-back work cycles', () => { - let orchestration: ISessionOrchestration = { ...base, notifyOnIdle: 'always' }; - for (let cycle = 0; cycle < 2; cycle++) { - const started = transitionSessionCoordination(SessionStatus.InProgress, orchestration); - assert.strictEqual(started.notify, false); - orchestration = started.orchestration!; - const completed = transitionSessionCoordination(SessionStatus.Idle, orchestration); - assert.strictEqual(completed.notify, true); - orchestration = completed.orchestration!; - } - }); -}); diff --git a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts index 94827add02e..1bec533c433 100644 --- a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts @@ -631,6 +631,35 @@ suite('SessionDatabase', () => { }); }); + // ---- Turn delegation ------------------------------------------------- + + suite('turn delegation', () => { + + test('restores delegation by host or provider turn id', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setTurnDelegation('host-turn', '{"sourceSession":"copilot:/source"}'); + await db.setTurnEventId('host-turn', 'provider-turn'); + + assert.deepStrictEqual([...(await db.getTurnDelegations()).entries()], [ + ['host-turn', '{"sourceSession":"copilot:/source"}'], + ['provider-turn', '{"sourceSession":"copilot:/source"}'], + ]); + }); + + test('truncation and remapping follow the owning turn', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setTurnDelegation('old-1', '{"sourceSession":"copilot:/one"}'); + await db.setTurnDelegation('old-2', '{"sourceSession":"copilot:/two"}'); + + await db.remapTurnIds(new Map([['old-1', 'new-1']])); + await db.deleteTurnsAfter('new-1'); + + assert.deepStrictEqual([...(await db.getTurnDelegations()).entries()], [ + ['new-1', '{"sourceSession":"copilot:/one"}'], + ]); + }); + }); + // ---- Turn checkpoint refs ------------------------------------------- suite('turn checkpoint refs', () => { diff --git a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts index 4e907b9f877..fee96ba3bf1 100644 --- a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts @@ -11,7 +11,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import type { IAgentCreateSessionConfig, IAgentModelInfo, IAgentSessionMetadata } from '../../common/agent.js'; import { SessionStatus } from '../../common/state/protocol/channels-session/state.js'; -import { buildChatUri, buildDefaultChatUri, MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, withSessionGitState, withSessionGitHubState, withSessionOrchestration, type ISessionOrchestration, type ModelSelection, type ResponsePart, type ToolCallState, type Turn } from '../../common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, MessageKind, readSessionCreationReference, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, withSessionGitState, withSessionGitHubState, type ModelSelection, type ResponsePart, type ToolCallState, type Turn } from '../../common/state/sessionState.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { SessionServerToolName } from '../../common/serverToolNames.js'; import { withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; @@ -52,12 +52,11 @@ suite('SessionServerTools', () => { } function executionContext(sessionUri: string) { - return { sessionUri, chatUri: buildDefaultChatUri(sessionUri) }; + return { sessionUri, chatUri: buildDefaultChatUri(sessionUri), turnId: 'turn-1' }; } - function createAccessor(overrides?: Partial & { onCreate?: (config: IAgentCreateSessionConfig) => void; onPrompt?: (session: URI, chat: URI, prompt: string) => void; onCreateChat?: (session: URI, chat: URI, options?: { title?: string; model?: ModelSelection }) => void; onRenameChat?: (session: URI, chat: URI, title: string) => void; onDelete?: (session: URI) => void; depths?: Map; orchestrations?: Map }): ISessionServerToolAccessor { + function createAccessor(overrides?: Partial & { onCreate?: (config: IAgentCreateSessionConfig) => void; onPrompt?: (...args: Parameters) => void; onCreateChat?: (session: URI, chat: URI, options?: { title?: string; model?: ModelSelection }) => void; onRenameChat?: (session: URI, chat: URI, title: string) => void; onDelete?: (session: URI) => void; depths?: Map }): ISessionServerToolAccessor { const depths = overrides?.depths ?? new Map(); - const orchestrations = overrides?.orchestrations ?? new Map(); return { isActiveAgentTitleGenerationEnabled: overrides?.isActiveAgentTitleGenerationEnabled ?? (() => true), listSessions: overrides?.listSessions ?? (async () => [sessionMeta('s1', SessionStatus.InProgress, workspace)]), @@ -65,7 +64,7 @@ suite('SessionServerTools', () => { createSession: overrides?.createSession ?? (async config => { overrides?.onCreate?.(config); return URI.parse('copilot:/new'); }), getModels: overrides?.getModels ?? (() => [model]), getCreationDefaults: overrides?.getCreationDefaults ?? (() => undefined), - startPrompt: overrides?.startPrompt ?? (async (session, chat, prompt) => { overrides?.onPrompt?.(session, chat, prompt); }), + startPrompt: overrides?.startPrompt ?? (async (session, chat, prompt, delegation) => { overrides?.onPrompt?.(session, chat, prompt, delegation); }), createChat: overrides?.createChat ?? (async (session, chat, options) => { overrides?.onCreateChat?.(session, chat, options); }), renameChat: overrides?.renameChat ?? (async (session, chat, title) => { overrides?.onRenameChat?.(session, chat, title); return { title }; }), reportToolError: overrides?.reportToolError ?? (() => { }), @@ -73,7 +72,17 @@ suite('SessionServerTools', () => { getChatContext: overrides?.getChatContext ?? (async () => undefined), getSessionSpawnDepth: overrides?.getSessionSpawnDepth ?? (session => depths.get(session.toString()) ?? 0), setSessionSpawnDepth: overrides?.setSessionSpawnDepth ?? ((session, depth) => { depths.set(session.toString(), depth); }), - setSessionOrchestration: overrides?.setSessionOrchestration ?? (async (session, orchestration) => { orchestrations.set(session.toString(), orchestration); }), + }; + } + + function createConfigSnapshot(config: IAgentCreateSessionConfig | undefined) { + if (!config) { + return undefined; + } + const { _meta, ...rest } = config; + return { + ...rest, + createdBySession: readSessionCreationReference(_meta), }; } @@ -89,7 +98,8 @@ suite('SessionServerTools', () => { assert.strictEqual(sessionToolRequiresConfirmation(SessionServerToolName.ListSessions), false); assert.strictEqual(sessionToolRequiresConfirmation(SessionServerToolName.GetCurrentSession), false); assert.strictEqual(sessionToolRequiresConfirmation(SessionServerToolName.GetSessionContext), false); - assert.strictEqual(sessionServerToolDefinitions.find(def => def.name === SessionServerToolName.CreateSession)?.inputSchema?.properties?.parentSession, undefined); + assert.deepStrictEqual(Object.keys(sessionServerToolDefinitions.find(def => def.name === SessionServerToolName.CreateSession)?.inputSchema?.properties ?? {}), ['workspace', 'prompt', 'model']); + assert.strictEqual(sessionServerToolDefinitions.find(def => def.name === SessionServerToolName.ListSessions)?.inputSchema?.properties?.label, undefined); assert.deepStrictEqual(sessionServerToolDefinitions.slice(4, 5).map(def => ({ name: def.name, required: def.inputSchema?.required })), [ { name: SessionServerToolName.RenameChat, required: ['title'] }, ]); @@ -252,80 +262,6 @@ suite('SessionServerTools', () => { }); }); - suite('orchestration metadata', () => { - test('serializeSessions and filters expose orchestration relationships', () => { - const child = { - ...sessionMeta('child', SessionStatus.Idle, workspace), - _meta: withSessionOrchestration(undefined, { - parentSession: 'copilot:/parent', - creatorSession: 'copilot:/creator', - coordinateWithCreator: true, - notifyOnIdle: 'once', - label: 'research', - }), - }; - - assert.deepStrictEqual({ - serialized: JSON.parse(serializeSessions([child])).sessions[0], - byParent: filterSessions([child], getListSessionsArgs({ parentSession: 'agent-host-session://copilot/parent' })).map(session => session.session.toString()), - byLabel: filterSessions([child], getListSessionsArgs({ label: 'research' })).map(session => session.session.toString()), - }, { - serialized: { - session: 'copilot:/child', - openLink: 'agent-host-session://copilot/child', - title: 'title-child', - status: 'idle', - workingDirectory: workspace.toString(), - parentSession: 'copilot:/parent', - creator: 'copilot:/creator', - label: 'research', - notifyOnIdle: 'once', - }, - byParent: ['copilot:/child'], - byLabel: ['copilot:/child'], - }); - }); - - test('serializeSessions hides a disabled creator relationship from the child', () => { - const child = { - ...sessionMeta('child', SessionStatus.Idle, workspace), - _meta: withSessionOrchestration(undefined, { - parentSession: 'copilot:/parent', - creatorSession: 'copilot:/parent', - coordinateWithCreator: false, - label: 'private-child', - }), - }; - - assert.deepStrictEqual({ - child: JSON.parse(serializeSessions([child], 'copilot:/child')).sessions[0], - parent: JSON.parse(serializeSessions([child], 'copilot:/parent')).sessions[0], - childFilter: filterSessions([child], getListSessionsArgs({ parentSession: 'copilot:/parent' }), 'copilot:/child'), - parentFilter: filterSessions([child], getListSessionsArgs({ parentSession: 'copilot:/parent' }), 'copilot:/parent').map(session => session.session.toString()), - }, { - child: { - session: 'copilot:/child', - openLink: 'agent-host-session://copilot/child', - title: 'title-child', - status: 'idle', - workingDirectory: workspace.toString(), - label: 'private-child', - }, - parent: { - session: 'copilot:/child', - openLink: 'agent-host-session://copilot/child', - title: 'title-child', - status: 'idle', - workingDirectory: workspace.toString(), - parentSession: 'copilot:/parent', - label: 'private-child', - }, - childFilter: [], - parentFilter: ['copilot:/child'], - }); - }); - }); - test('serializeSessions preserves remote project roots and multiple working directories', () => { const project = URI.parse('vscode-remote://ssh-remote+example/home/me/app'); const primary = URI.parse('vscode-remote://ssh-remote+example/home/me/app-worktree'); @@ -387,7 +323,6 @@ suite('SessionServerTools', () => { assert.strictEqual(byId.model?.id, 'gpt-4o'); const byName = getCreateSessionArgs({ workspace: workspace.toString(), prompt: 'hi', model: 'GPT-4o' }, sessions, [model]); assert.strictEqual(byName.model?.name, 'GPT-4o'); - assert.strictEqual(byName.coordinateWithCreator, true); }); test('getCreateSessionArgs resolves a unique project name to its configured root', () => { @@ -437,48 +372,34 @@ suite('SessionServerTools', () => { const store = new DisposableStore(); const stateManager = store.add(new AgentHostStateManager(new NullLogService())); let created: IAgentCreateSessionConfig | undefined; - let prompted: { chat: URI; prompt: string } | undefined; - const orchestrations = new Map(); - const accessor = createAccessor({ orchestrations, onCreate: c => { created = c; }, onPrompt: (_s, chat, prompt) => { prompted = { chat, prompt }; } }); + let prompted: { chat: URI; prompt: string; delegation: Parameters[3] } | undefined; + const accessor = createAccessor({ onCreate: c => { created = c; }, onPrompt: (_s, chat, prompt, delegation) => { prompted = { chat, prompt, delegation }; } }); const group = createSessionServerToolGroup(accessor); const text = await group.execute(stateManager, executionContext('copilot:/caller'), SessionServerToolName.CreateSession, { workspace: workspace.toString(), prompt: 'do it', model: 'gpt-4o' }); - assert.deepStrictEqual(created, { workingDirectories: [workspace], provider: 'copilot', model: { id: 'gpt-4o' } }); + assert.deepStrictEqual(createConfigSnapshot(created), { + workingDirectories: [workspace], + provider: 'copilot', + model: { id: 'gpt-4o' }, + createdBySession: { + session: 'copilot:/caller', + chat: buildDefaultChatUri('copilot:/caller'), + turnId: 'turn-1', + }, + }); assert.strictEqual(prompted?.prompt, 'do it'); assert.strictEqual(prompted?.chat.toString(), buildDefaultChatUri(URI.parse('copilot:/new'))); + assert.deepStrictEqual(prompted?.delegation, { + sourceSession: 'copilot:/caller', + sourceChat: buildDefaultChatUri('copilot:/caller'), + sourceTurnId: 'turn-1', + }); assert.ok(text.includes('agent-host-session://copilot/new'), 'result carries the open-session link for the pill'); assert.ok(!text.includes('copilot:/new'), 'result does not echo the raw backend session URI'); - assert.deepStrictEqual(orchestrations.get('copilot:/new'), { - parentSession: 'copilot:/caller', - creatorSession: 'copilot:/caller', - coordinateWithCreator: true, - }); store.dispose(); }); - test('create_session records explicit orchestration options', async () => { - const orchestrations = new Map(); - const sessions = [sessionMeta('caller', SessionStatus.InProgress, workspace)]; - const accessor = createAccessor({ orchestrations, listSessions: async () => sessions }); - - await applyCreateSessionTool(accessor, { - workspace: workspace.toString(), - prompt: 'do it', - coordinateWithCreator: false, - notifyOnIdle: 'always', - label: 'research', - }, URI.parse('copilot:/caller')); - - assert.deepStrictEqual(orchestrations.get('copilot:/new'), { - parentSession: 'copilot:/caller', - creatorSession: 'copilot:/caller', - coordinateWithCreator: false, - notifyOnIdle: 'always', - label: 'research', - }); - }); - test('create_session inherits the calling chat model and permission config', async () => { const source = URI.parse(buildChatUri('copilot:/caller', 'peer')); let creationSource: URI | undefined; @@ -505,13 +426,17 @@ suite('SessionServerTools', () => { assert.deepStrictEqual({ creationSource: creationSource?.toString(), - created, + created: createConfigSnapshot(created), }, { creationSource: source.toString(), created: { workingDirectories: [workspace], provider: 'copilot', model: { id: 'gpt-inherited' }, + createdBySession: { + session: 'copilot:/caller', + chat: source.toString(), + }, config: { autoApprove: 'autoApprove', permissions: { allow: ['shell'], deny: ['write'] }, @@ -533,9 +458,13 @@ suite('SessionServerTools', () => { await applyCreateSessionTool(accessor, { workspace: workspace.toString(), prompt: 'do it' }, URI.parse('claude:/source')); - assert.deepStrictEqual(created, { + assert.deepStrictEqual(createConfigSnapshot(created), { workingDirectories: [workspace], provider: 'claude', + createdBySession: { + session: 'claude:/source', + chat: 'claude:/source', + }, config: { permissionMode: 'acceptEdits' }, }); }); @@ -561,10 +490,14 @@ suite('SessionServerTools', () => { model: 'claude-sonnet', }, URI.parse('copilot:/source')); - assert.deepStrictEqual(created, { + assert.deepStrictEqual(createConfigSnapshot(created), { workingDirectories: [remoteProject], provider: 'claude', model: { id: 'claude-sonnet' }, + createdBySession: { + session: 'copilot:/source', + chat: 'copilot:/source', + }, }); }); @@ -649,7 +582,7 @@ suite('SessionServerTools', () => { }); test('getListSessionsArgs validates filter input', () => { - assert.deepStrictEqual(getListSessionsArgs({}), { session: undefined, status: undefined, workspace: undefined, withChanges: undefined, unread: undefined, withPullRequest: undefined, includeArchived: undefined, createdAfter: undefined, createdBefore: undefined, parentSession: undefined, label: undefined }); + assert.deepStrictEqual(getListSessionsArgs({}), { session: undefined, status: undefined, workspace: undefined, withChanges: undefined, unread: undefined, withPullRequest: undefined, includeArchived: undefined, createdAfter: undefined, createdBefore: undefined }); assert.throws(() => getListSessionsArgs({ status: ['bogus'] }), /status/); assert.throws(() => getListSessionsArgs({ withChanges: 'yes' }), /withChanges/); assert.throws(() => getListSessionsArgs({ includeArchived: 'no' }), /includeArchived/); @@ -724,13 +657,14 @@ suite('SessionServerTools', () => { test('create_chat adds a chat to the session, starts the prompt, and returns an open link', async () => { let createdChat: { session: URI; chat: URI; options?: { title?: string; model?: ModelSelection } } | undefined; - let prompted: { session: URI; chat: URI; prompt: string } | undefined; + let prompted: { session: URI; chat: URI; prompt: string; delegation: Parameters[3] } | undefined; const accessor = createAccessor({ listSessions: async () => [sessionMeta('s1', SessionStatus.Idle, workspace)], onCreateChat: (session, chat, options) => { createdChat = { session, chat, options }; }, - onPrompt: (session, chat, prompt) => { prompted = { session, chat, prompt }; }, + onPrompt: (session, chat, prompt, delegation) => { prompted = { session, chat, prompt, delegation }; }, }); - const result = await applyCreateChatTool(accessor, { session: 'copilot:/s1', prompt: 'do it', title: 'T', model: 'gpt-4o' }); + const source = URI.parse(buildDefaultChatUri('copilot:/s1')); + const result = await applyCreateChatTool(accessor, { session: 'copilot:/s1', prompt: 'do it', title: 'T', model: 'gpt-4o' }, source, 'turn-1'); assert.strictEqual(result.session, 'copilot:/s1'); const chatId = URI.parse(result.chat).authority; assert.strictEqual(result.openLink, `agent-host-session://copilot/s1?chat=${chatId}`); @@ -740,6 +674,11 @@ suite('SessionServerTools', () => { assert.strictEqual(createdChat?.chat.toString(), result.chat); assert.strictEqual(prompted?.chat.toString(), result.chat); assert.strictEqual(prompted?.prompt, 'do it'); + assert.deepStrictEqual(prompted?.delegation, { + sourceSession: 'copilot:/s1', + sourceChat: source.toString(), + sourceTurnId: 'turn-1', + }); }); test('rename titles normalize presentation without truncating agent input', () => { @@ -964,45 +903,38 @@ suite('SessionServerTools', () => { }); test('send_message targets the default chat / a specific chat, refuses the current chat, and validates', async () => { - const prompts: { session: URI; chat: URI; prompt: string }[] = []; + const prompts: { session: URI; chat: URI; prompt: string; delegation: Parameters[3] }[] = []; const accessor = createAccessor({ listSessions: async () => [sessionMeta('s1', SessionStatus.Idle, workspace), sessionMeta('s2', SessionStatus.Idle, workspace)], - onPrompt: (session, chat, prompt) => { prompts.push({ session, chat, prompt }); }, + onPrompt: (session, chat, prompt, delegation) => { prompts.push({ session, chat, prompt, delegation }); }, }); const currentChannel = buildDefaultChatUri('copilot:/s1'); // Explicit session -> owning session's default chat. - const toSession = await applySendMessageTool(accessor, { session: 'copilot:/s2', message: 'hi' }, currentChannel); + const toSession = await applySendMessageTool(accessor, { session: 'copilot:/s2', message: 'hi' }, currentChannel, 'turn-1'); assert.strictEqual(prompts.at(-1)?.session.toString(), 'copilot:/s2'); assert.strictEqual(prompts.at(-1)?.chat.toString(), buildDefaultChatUri('copilot:/s2')); assert.strictEqual(prompts.at(-1)?.prompt, 'hi'); + assert.deepStrictEqual(prompts.at(-1)?.delegation, { + sourceSession: 'copilot:/s1', + sourceChat: currentChannel, + sourceTurnId: 'turn-1', + }); assert.ok(toSession.includes('agent-host-session://copilot/s2')); // A create_chat open link -> that specific chat channel. await applySendMessageTool(accessor, { session: 'agent-host-session://copilot/s2?chat=c9', message: 'yo' }, currentChannel); assert.strictEqual(prompts.at(-1)?.chat.toString(), buildChatUri('copilot:/s2', 'c9')); + await applySendMessageTool(accessor, { session: 'agent-host-session://copilot/s1?chat=c9', message: 'same session' }, currentChannel, 'turn-2'); + assert.deepStrictEqual(prompts.at(-1)?.delegation, { + sourceSession: 'copilot:/s1', + sourceChat: currentChannel, + sourceTurnId: 'turn-2', + }); + // Refuses messaging the exact current chat channel (self-loop guard). await assert.rejects(() => applySendMessageTool(accessor, { session: 'copilot:/s1', message: 'loop' }, currentChannel), /current chat/); - const privateChild = { - ...sessionMeta('child', SessionStatus.Idle, workspace), - _meta: withSessionOrchestration(undefined, { - parentSession: 'copilot:/s2', - creatorSession: 'copilot:/s2', - coordinateWithCreator: false, - }), - }; - const privateAccessor = createAccessor({ - listSessions: async () => [privateChild, sessionMeta('s2', SessionStatus.Idle, workspace)], - }); - await assert.rejects( - () => applySendMessageTool(privateAccessor, { session: 'copilot:/s2', message: 'blocked' }, buildDefaultChatUri('copilot:/child')), - /not allowed to coordinate with its creator/, - ); - await assert.rejects( - () => applyCreateChatTool(privateAccessor, { session: 'copilot:/s2', prompt: 'blocked' }, URI.parse(buildDefaultChatUri('copilot:/child'))), - /not allowed to coordinate with its creator/, - ); // Unknown session and missing session/message are rejected. await assert.rejects(() => applySendMessageTool(accessor, { session: 'copilot:/nope', message: 'x' }, currentChannel), /known session/); assert.throws(() => getSendMessageArgs({ message: 'x' }, []), /session/); diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 8d323834049..0e9366352ed 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -84,6 +84,13 @@ An `ISession` has a provider-owned resource URI, provider identifier, session ty Consumers derive state from those observables. Provider events announce catalog membership changes; they are not a parallel state store. +Providers may expose immutable creation provenance when a session was created by +another session. `createdBySession` identifies the creating session and may also +identify its chat and turn. The reference is observable so list presentation can +keep related sessions together when creation metadata arrives after discovery. +Creation paths that know the reference include it in the initial session +publication. + ### Sessions and chats A session groups one or more chats and exposes a main chat. Providers advertise multi-chat, fork, side-chat, and other operations through observable capabilities. Shared code gates affordances on those capabilities rather than provider identifiers. diff --git a/src/vs/sessions/SESSIONS_LIST.md b/src/vs/sessions/SESSIONS_LIST.md index ed85035c8e3..0367ff0c1e6 100644 --- a/src/vs/sessions/SESSIONS_LIST.md +++ b/src/vs/sessions/SESSIONS_LIST.md @@ -43,6 +43,11 @@ Archived - A valid custom-group membership places an unpinned, unarchived session in that group, including a quick chat. - Remaining unpinned quick chats appear in the dedicated chats section. - Remaining sessions follow the selected workspace or date grouping. +- A regular session created by another regular session is initially placed + immediately after its creator. While it has neither custom-group membership + nor an explicit ungrouped preference, it inherits the creator's custom group + when one becomes available. Subsequent user grouping, ungrouping, and + reordering are ordinary persisted list state. The active session remains visible even when a filter would otherwise exclude it. diff --git a/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts b/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts index b14b6196e27..6cdd3e57293 100644 --- a/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts @@ -79,7 +79,8 @@ export class OpenSessionLinkOpenerContribution extends Disposable implements IWo } const chatId = parseOpenSessionLinkChatId(resource); if (chatId) { - await this._sessionsService.openChat(session, session.resource.with({ fragment: chatId })); + const chatResource = session.resource.with({ fragment: chatId }); + await this._sessionsService.openChat(session, chatResource); return true; } await this._sessionsService.openSession(session.resource); diff --git a/src/vs/sessions/contrib/chat/browser/requestOriginProvider.contribution.ts b/src/vs/sessions/contrib/chat/browser/requestOriginProvider.contribution.ts index b7c4402e717..e188eb583b9 100644 --- a/src/vs/sessions/contrib/chat/browser/requestOriginProvider.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/requestOriginProvider.contribution.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { AGENT_HOST_SESSION_LINK_SCHEME } from '../../../../platform/agentHost/common/openSessionLink.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IChatRequestOriginService } from '../../../../workbench/contrib/chat/common/chatRequestOrigin.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; @@ -15,10 +17,14 @@ class SessionsChatRequestOriginProviderContribution extends Disposable implement constructor( @IChatRequestOriginService requestOriginService: IChatRequestOriginService, @ISessionsService sessionsService: ISessionsService, + @IOpenerService openerService: IOpenerService, ) { super(); this._register(requestOriginService.registerOpener({ open: async origin => { + if (origin.sourceSessionResource.scheme === AGENT_HOST_SESSION_LINK_SCHEME) { + return openerService.open(origin.sourceSessionResource); + } await sessionsService.openSession(origin.sourceSessionResource); return true; }, diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 5dc55fba66a..e80c56ce86f 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -35,6 +35,8 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.inputPills', "When session metadata or active-turn status pills appear above the input, press Tab to reach them, use the Left and Right arrow keys to move between them, and press Enter or Space to activate one. Right-click a pill to choose which pills are shown.")); content.push(localize('sessionsChat.externalSessionFilter', "The Sessions list Filter menu includes an External submenu. Use it to choose whether external sessions from another application are shown for the last 24 hours, the last 7 days, always, or not at all.")); content.push(localize('sessionsChat.externalSessionBanner', "When you first open a session created in another application, a banner appears at the top of the chat. Use Tab to reach its external-session picker, choose an option, and activate Save. The Close action dismisses the banner without changing the setting. Saving or closing permanently dismisses the banner.")); + content.push(localize('sessionsChat.delegatedMessage', "Messages sent by another session or chat show a source annotation above the message. Press Tab to focus the annotation, then press Enter or Space to open the source chat.")); + content.push(localize('sessionsChat.createdBySession', "When a session was created by another session, focus it in the Sessions list and use the Show Hover command{0}. Move focus to the Created by link, then press Enter or Space to open the creator session.", '')); content.push(localize('sessionsChat.promptOptions', "When prompt options appear above the new-session input, use Tab and Shift+Tab to move between them, then press Enter or Space to insert one. You can select a different option while the input is empty, exactly matches the inserted prompt, or only has its editable placeholder removed; other edits disable the options without hiding them. Clearing the input also clears the selected option. Use the Close action to hide the options and return focus to the input.")); content.push(localize('sessionsChat.promptTemplatePlaceholder', "When the new-session prompt contains a highlighted task placeholder, place the caret inside it and replace it{0} to type your task.", ``)); content.push(localize('sessionsChat.feedbackComments', "When feedback comments are available for a new session, a comments banner appears above the input. You can send the comments without typing a message, or focus the Reveal button to open the first comment in its editor.")); diff --git a/src/vs/sessions/contrib/chat/test/browser/openSessionLinkOpener.test.ts b/src/vs/sessions/contrib/chat/test/browser/openSessionLinkOpener.test.ts index 206d736e570..b4ed4540a62 100644 --- a/src/vs/sessions/contrib/chat/test/browser/openSessionLinkOpener.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/openSessionLinkOpener.test.ts @@ -52,7 +52,9 @@ suite('OpenSessionLinkOpenerContribution', () => { } }; const sessionResource = URI.parse('copilotcli:/session-1'); - const session = upcastPartial({ resource: sessionResource }); + const chatResource = sessionResource.with({ fragment: 'chat-2' }); + const chat = upcastPartial({ resource: chatResource }); + const session = upcastPartial({ resource: sessionResource, chats: observableValue('chats', [chat]) }); const sessionsManagementService = new class extends mock() { override getSessions(): ISession[] { return [session]; @@ -96,7 +98,7 @@ suite('OpenSessionLinkOpenerContribution', () => { assert.deepStrictEqual({ results: [ await registeredOpener.open(buildOpenSessionLinkUri(sessionResource)), - await registeredOpener.open(buildOpenSessionLinkUri(sessionResource, 'chat-2')), + await registeredOpener.open(buildOpenSessionLinkUri(sessionResource, 'chat-2', 'turn-1')), ], opened, }, { @@ -229,6 +231,7 @@ suite('OpenSessionLinkOpenerContribution', () => { title: 'Fix authentication redirect loop', location: undefined, pullRequests: undefined, + createdBy: undefined, providerLabels: ['Local Agent Host'], }, unknown: undefined, diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index a65be024ec3..16db9fab4c5 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -30,7 +30,7 @@ import type { IAgentSubscription } from '../../../../../platform/agentHost/commo import { ResolveSessionConfigResult, type SessionConfigPropertySchema } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { AgentCustomization, ChangesSummary, ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, type ClientPluginCustomization, Customization, CustomizationEnablementKind, CustomizationType, type CustomizationEnablement, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionState, SessionSummary, type Changeset } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isChatAction, isSessionAction, NotificationType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; -import { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, DEFAULT_CHAT_ID, getSessionChatResource, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, ROOT_STATE_URI, SESSION_META_MULTI_ROOT_KEY, SessionMeta, SessionSourceControlOutcome, StateComponents, withSessionExternal, withSessionGitHubState, withSessionMultiRootMetadata, withSessionStatusFlag, withSessionWorkspaceless, type ChatState, type ChatSummary, type ISessionGitHubState, type ISessionGitState, type ISessionMultiRootMetadata } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, DEFAULT_CHAT_ID, getSessionChatResource, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionCreationReference, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, ROOT_STATE_URI, SESSION_META_MULTI_ROOT_KEY, SessionMeta, SessionSourceControlOutcome, StateComponents, withSessionCreationReference, withSessionExternal, withSessionGitHubState, withSessionMultiRootMetadata, withSessionStatusFlag, withSessionWorkspaceless, type ChatState, type ChatSummary, type ISessionCreationReference as IProtocolSessionCreationReference, type ISessionGitHubState, type ISessionGitState, type ISessionMultiRootMetadata } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -50,7 +50,7 @@ import { getRegisteredLanguageModels, resolveConfiguredModel, resolveModelIdenti import { buildMutableConfigSchema, IAgentHostMcpServer, IAgentHostSessionsProvider, resolvedConfigsEqual } from '../../../../common/agentHostSessionsProvider.js'; import { agentHostSessionWorkspaceKey } from '../../../../common/agentHostSessionWorkspace.js'; import { isSessionConfigComplete } from '../../../../common/sessionConfig.js'; -import { ChatInteractivity, ChatModelSource, ChatOriginKind, DEFAULT_CHAT_CAPABILITIES, effectiveChatInteractivity, IChat, IChatCapabilities, IGitHubInfo, IGitHubIssueRef, IGitHubPullRequestRef, ISession, ISessionAgentRef, ISessionArtifact, ISessionCapabilities, ISessionChangeset, ISessionChangesSummary, ISessionChatCustomization, ISessionFileChange, ISessionTurnFileChange, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection, sessionFileChangesEqual, sessionWorkspaceEqual, SessionStatus, SessionTypeAuthRequirement, toSessionId, TURN_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; +import { ChatInteractivity, ChatModelSource, ChatOriginKind, DEFAULT_CHAT_CAPABILITIES, effectiveChatInteractivity, IChat, IChatCapabilities, IGitHubInfo, IGitHubIssueRef, IGitHubPullRequestRef, ISession, ISessionAgentRef, ISessionArtifact, ISessionCapabilities, ISessionChangeset, ISessionChangesSummary, ISessionChatCustomization, ISessionCreationReference, ISessionFileChange, ISessionTurnFileChange, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection, sessionFileChangesEqual, sessionWorkspaceEqual, SessionStatus, SessionTypeAuthRequirement, toSessionId, TURN_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; import { dedupeLinks, getPresentedArtifacts, linkKey, partitionSessionArtifacts } from './agentHostSessionArtifacts.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProviderCreateSessionOptions, ISessionWorktreeConfiguration } from '../../../../services/sessions/common/sessionsProvider.js'; @@ -202,6 +202,7 @@ interface ISerializedSessionMetadata { readonly workspaceless?: boolean; readonly external?: boolean; readonly multiRoot?: ISessionMultiRootMetadata; + readonly createdBySession?: IProtocolSessionCreationReference; } /** @@ -225,6 +226,7 @@ function serializeMetadata(meta: IAgentSessionMetadata): ISerializedSessionMetad workspaceless: readSessionWorkspaceless(meta._meta) || undefined, external: readSessionExternal(meta._meta) || undefined, multiRoot: readSessionMultiRootMetadata(meta._meta), + createdBySession: readSessionCreationReference(meta._meta), }; } @@ -234,6 +236,9 @@ function deserializeMetadata(raw: ISerializedSessionMetadata): IAgentSessionMeta _meta = withSessionExternal(_meta, raw.external === true); _meta = withSessionMultiRootMetadata(_meta, readSessionMultiRootMetadata({ [SESSION_META_MULTI_ROOT_KEY]: raw.multiRoot })); _meta = withSessionGitHubState(_meta, raw.github); + if (raw.createdBySession) { + _meta = withSessionCreationReference(_meta, raw.createdBySession); + } return { session: URI.parse(raw.session), startTime: raw.startTime, @@ -543,6 +548,8 @@ export interface IAgentHostAdapterOptions { * (cloud sandbox: provider `copilot`, sessions `ahp-session:/`). Defaults to the provider. */ readonly backendSessionScheme?: string; + /** Maps a backend session URI to the client resource used by this host. */ + readonly mapBackendSessionResource: (resource: URI) => URI; } /** @@ -718,6 +725,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { readonly isQuickChat: IObservable; readonly isAutomation = observableValue('isAutomation', false); readonly isExternal: IObservable; + readonly createdBySession: IObservable; /** See {@link ISession.worktreePending}. */ readonly worktreePending: IObservable; readonly title: ISettableObservable; @@ -935,6 +943,18 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this._meta = metadata._meta; this._metaObs = observableValue('agentHostSessionMeta', this._meta); this.isExternal = derived(this, reader => readSessionExternal(this._metaObs.read(reader))); + this.createdBySession = derived(this, reader => { + const creationReference = readSessionCreationReference(this._metaObs.read(reader)); + if (!creationReference) { + return undefined; + } + const session = this._options.mapBackendSessionResource(URI.parse(creationReference.session)); + const parsedChat = creationReference.chat ? parseChatUri(creationReference.chat) : undefined; + const chat = parsedChat + ? session.with({ fragment: parsedChat.chatId === DEFAULT_CHAT_ID ? '' : parsedChat.chatId }) + : undefined; + return { session, chat, turnId: creationReference.turnId }; + }); this.artifacts = derivedOpts({ owner: this, equalsFn: structuralEquals }, reader => { const meta = this._metaObs.read(reader); return getPresentedArtifacts(partitionSessionArtifacts(meta), toGitHubPromotion(meta).surfacedLinks); @@ -2760,6 +2780,15 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return agentProvider; } + protected _logicalSessionTypeForBackendScheme(backendScheme: string): string { + return backendScheme; + } + + private _mapBackendSessionResource(resource: URI): URI { + const sessionType = this._logicalSessionTypeForBackendScheme(resource.scheme); + return resource.with({ scheme: this.resourceSchemeForProvider(sessionType) }); + } + /** Build an adapter for the given metadata. */ protected createAdapter(meta: IAgentSessionMetadata): AgentHostSessionAdapter { const provider = AgentSession.provider(meta.session); @@ -2777,6 +2806,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement getConnection: () => this.connection, agentCapabilities: this._agentCapabilities, backendSessionScheme: this._backendSessionScheme(provider), + mapBackendSessionResource: resource => this._mapBackendSessionResource(resource), ...this._adapterOptions(), } satisfies IAgentHostAdapterOptions; @@ -3155,6 +3185,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement instantiationService: this._instantiationService, getConnection: () => this.connection, agentCapabilities: this._agentCapabilities, + mapBackendSessionResource: resource => this._mapBackendSessionResource(resource), ...this._adapterOptions(), } satisfies IAgentHostAdapterOptions); } catch (err) { diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 9f8280a0cfd..19c5c4995a6 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -20,7 +20,7 @@ import { AgentHostCodexAgentEnabledSettingId, IAgentHostService } from '../../.. import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, CustomizationEnablementKind, CustomizationLoadStatus, CustomizationType, McpServerStatus, MessageKind, SessionLifecycle, type AgentCustomization, type AgentInfo, type ChangesSummary, type Customization, type RootState, type SessionActiveClient, type SessionConfigState, type SessionState, type SessionSummary } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChangesetStatus, ResponsePartKind, SessionSourceControlOutcome, SessionStatus as ProtocolSessionStatus, StateComponents, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, withSessionEhcliAdoptable, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionWorkspaceless, type ChangesetState, type ChatState, type ChatSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChangesetStatus, ResponsePartKind, SessionSourceControlOutcome, SessionStatus as ProtocolSessionStatus, StateComponents, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, withSessionCreationReference, withSessionEhcliAdoptable, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionWorkspaceless, type ChangesetState, type ChatState, type ChatSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { SessionArtifactType, withSessionArtifacts } from '../../../../../../platform/agentHost/common/sessionArtifacts.js'; import { ActionType, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type ChatAction, type SessionAction, type TerminalAction, type INotification, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; @@ -370,8 +370,9 @@ class MockAgentHostService extends mock() { // ---- Test helpers ----------------------------------------------------------- -function createSession(id: string, opts?: { provider?: string; summary?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number; quickChat?: boolean; multiRoot?: { workspaceFile: string }; adoptable?: boolean }): IAgentSessionMetadata { - let _meta = opts?.quickChat ? withSessionWorkspaceless(undefined, true) : undefined; +function createSession(id: string, opts?: { provider?: string; summary?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number; quickChat?: boolean; multiRoot?: { workspaceFile: string }; adoptable?: boolean; _meta?: IAgentSessionMetadata['_meta'] }): IAgentSessionMetadata { + let _meta = opts?._meta; + _meta = opts?.quickChat ? withSessionWorkspaceless(_meta, true) : _meta; _meta = withSessionMultiRootMetadata(_meta, opts?.multiRoot); if (opts?.adoptable) { _meta = withSessionEhcliAdoptable(_meta); @@ -1121,6 +1122,39 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('session metadata exposes its creation reference', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.addSession(createSession('created')); + + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + const session = provider.getSessions()[0]!; + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + + fireSessionMetaChanged(agentHost, 'created', withSessionCreationReference(undefined, { + session: 'claude:/creator', + chat: buildDefaultChatUri('claude:/creator'), + turnId: 'turn-1', + })); + + assert.deepStrictEqual({ + createdBySession: session.createdBySession?.get() && { + session: session.createdBySession.get()?.session.toString(), + chat: session.createdBySession.get()?.chat?.toString(), + turnId: session.createdBySession.get()?.turnId, + }, + changedEvents: changes.map(change => change.changed.map(changed => changed === session)), + }, { + createdBySession: { + session: 'agent-host-claude:/creator', + chat: 'agent-host-claude:/creator', + turnId: 'turn-1', + }, + changedEvents: [[true]], + }); + })); + test('getSessions populates from listSessions', () => runWithFakedTimers({ useFakeTimers: true }, async () => { agentHost.addSession(createSession('list-1', { summary: 'First' })); agentHost.addSession(createSession('list-2', { summary: 'Second' })); @@ -1398,6 +1432,47 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('hydrates creation provenance before the live list is available', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const storageService = disposables.add(new InMemoryStorageService()); + const previousHost = new MockAgentHostService(); + disposables.add(toDisposable(() => previousHost.dispose())); + previousHost.addSession(createSession('cached-created', { + summary: 'Cached Created', + _meta: withSessionCreationReference(undefined, { + session: 'copilot:/creator', + chat: buildChatUri('copilot:/creator', 'peer'), + turnId: 'turn-1', + }), + })); + createProvider(disposables, previousHost, undefined, { storageService }); + await timeout(0); + await storageService.flush(); + + const nextHost = new MockAgentHostService(); + disposables.add(toDisposable(() => nextHost.dispose())); + nextHost.setAuthenticationPending(true); + const nextProvider = createProvider(disposables, nextHost, undefined, { storageService }); + const restored = nextProvider.getSessions() + .map(session => ({ + title: session.title.get(), + createdBySession: session.createdBySession?.get() && { + session: session.createdBySession.get()?.session.toString(), + chat: session.createdBySession.get()?.chat?.toString(), + turnId: session.createdBySession.get()?.turnId, + }, + })) + .sort((a, b) => a.title.localeCompare(b.title)); + + assert.deepStrictEqual(restored, [{ + title: 'Cached Created', + createdBySession: { + session: 'agent-host-copilot:/creator', + chat: 'agent-host-copilot:/creator#peer', + turnId: 'turn-1', + }, + }]); + })); + test('hydrates a pull request icon persisted by a metadata-only update', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const storageService = disposables.add(new InMemoryStorageService()); const previousHost = new MockAgentHostService(); @@ -4389,6 +4464,7 @@ suite('LocalAgentHostSessionsProvider', () => { instantiationService, getConnection: () => undefined, agentCapabilities: capabilitiesObs, + mapBackendSessionResource: resource => resource.with({ scheme: `agent-host-${resource.scheme}` }), }; const adapters = Array.from({ length: 200 }, (_, index) => disposables.add(instantiationService.createInstance( AgentHostSessionAdapter, diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxReadOnlySessionHandler.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxReadOnlySessionHandler.ts index 46601c075e9..aa1a8231e4d 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxReadOnlySessionHandler.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxReadOnlySessionHandler.ts @@ -121,7 +121,7 @@ export class CloudSandboxReadOnlySessionHandler extends Disposable implements IC // interleave unrelated conversations rather than show more history. const chat = session.chats.get(session.defaultChat) ?? [...session.chats.values()][0]; const history: IChatSessionHistoryItem[] = chat - ? turnsToHistory(URI.parse(session.session), chat.turns, this._config.agentId, this._config.connectionAuthority) + ? turnsToHistory(URI.parse(session.session), chat.turns, this._config.agentId, this._config.connectionAuthority, undefined, undefined, undefined, undefined, this._config.agentId) : []; // The compute most likely died mid-turn, so the unfinished exchange is exactly the one the @@ -135,7 +135,7 @@ export class CloudSandboxReadOnlySessionHandler extends Disposable implements IC prompt: active.message.text, participant: this._config.agentId, variableData: messageToVariableData(active.message, this._config.connectionAuthority), - origin: messageToRequestOrigin(URI.parse(session.session), active.message, this._config.agentId), + origin: messageToRequestOrigin(URI.parse(session.session), active.message, this._config.agentId, this._config.agentId), }); history.push({ type: 'response', diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index b48c0741e6b..765722ed181 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -394,6 +394,11 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid return alias && agentProvider === alias.ui ? alias.backend : agentProvider; } + protected override _logicalSessionTypeForBackendScheme(backendScheme: string): string { + const alias = this._sessionSchemeAlias; + return alias && backendScheme === alias.backend ? alias.ui : backendScheme; + } + setAuthenticationPending(pending: boolean): void { // Sticky: once the first authentication pass settles, never surface // pending again. Subsequent re-auths happen silently in the background. diff --git a/src/vs/sessions/contrib/sessions/browser/sessionHoverContent.ts b/src/vs/sessions/contrib/sessions/browser/sessionHoverContent.ts index 8494cf90761..31d323b6305 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionHoverContent.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionHoverContent.ts @@ -43,11 +43,13 @@ export function getSessionDiffStats(session: ISession): { files: number; inserti export function getSessionSummaryHoverData( session: ISession, sessionsProvidersService: ISessionsProvidersService, + createdBy?: ISessionSummaryHoverData['createdBy'], ): ISessionSummaryHoverData { return { title: session.title.get() || getUntitledSessionTitle(session.isQuickChat?.get() ?? false), location: getLocation(session), pullRequests: getPullRequests(session), + createdBy, providerLabels: getProviderLabels(session, sessionsProvidersService), }; } diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 92f1449e2c1..61ceecb8453 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -44,6 +44,7 @@ import { chartsOrange } from '../../../../../platform/theme/common/colors/charts import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { ChatSessionArchiveActionWording, ChatSessionArchiveActionWordingSettingId, getChatSessionArchivedSectionLabel, getChatSessionArchiveActionWording } from '../../../../../platform/chat/common/sessionArchiveActions.js'; import { getSessionStatusMessage, getSessionWorkspaceKind, GITHUB_REMOTE_FILE_SCHEME, ISession, ISessionWorkspace, SessionStatus, SessionWorkspaceKind } from '../../../../services/sessions/common/session.js'; import { AgentSessionApprovalModel, agentSessionApprovalId, IAgentSessionApprovalInfo } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; @@ -78,6 +79,8 @@ import { IWorkbenchAssignmentService } from '../../../../../workbench/services/a // eslint-disable-next-line no-restricted-imports import { IAgentSessionsService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { IAgentHostFilterService } from '../../../../services/agentHostFilter/common/agentHostFilter.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { buildOpenSessionLinkUri } from '../../../../../platform/agentHost/common/openSessionLink.js'; import { LocalSelectionTransfer } from '../../../../../platform/dnd/browser/dnd.js'; import { DraggedSessionIdentifier, SessionsDataTransfers } from '../../../../browser/dnd.js'; import { IDragAndDropData } from '../../../../../base/browser/dnd.js'; @@ -427,12 +430,29 @@ class SessionItemRenderer implements ITreeRenderer void } | undefined { + const creationReference = session.createdBySession?.get(); + if (!creationReference) { + return undefined; + } + const creator = this.sessionsManagementService.getSession(creationReference.session); + const resolved = this.agentHostConnectionsService.resolveSessionResource(creationReference.session); + if (!creator || !resolved) { + return undefined; + } + const target = buildOpenSessionLinkUri(resolved.backendSession, creationReference.chat?.fragment, creationReference.turnId); + return { title: creator.title.get(), onOpen: () => this.openerService.open(target).catch(onUnexpectedError) }; + } + renderTemplate(container: HTMLElement): ISessionItemTemplate { const disposables = new DisposableStore(); const elementDisposables = disposables.add(new DisposableStore()); @@ -551,7 +571,7 @@ class SessionItemRenderer implements ITreeRenderer ({ - content: new SessionSummaryHoverWidget(getSessionSummaryHoverData(element, this.sessionsProvidersService)).domNode, + content: new SessionSummaryHoverWidget(getSessionSummaryHoverData(element, this.sessionsProvidersService, this.getCreatorHoverData(element))).domNode, appearance: { showPointer: true }, position: { hoverPosition: HoverPosition.RIGHT, forcePosition: true }, persistence: { hideOnHover: false }, @@ -1997,6 +2017,8 @@ export class SessionsList extends Disposable implements ISessionsList { @IWorkbenchAssignmentService private readonly assignmentService: IWorkbenchAssignmentService, @IConfigurationService private readonly configurationService: IConfigurationService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, + @IAgentHostConnectionsService private readonly agentHostConnectionsService: IAgentHostConnectionsService, + @IOpenerService private readonly openerService: IOpenerService, ) { super(); @@ -2072,6 +2094,9 @@ export class SessionsList extends Disposable implements ISessionsList { markdownRendererService, hoverService, sessionsProvidersService, + this._sessionsManagementService, + this.agentHostConnectionsService, + this.openerService, agentSessionsService, voicePlaybackService, ); @@ -3831,6 +3856,8 @@ export class SessionsFlatList extends Disposable { @IHoverService hoverService: IHoverService, @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, @IVoicePlaybackService voicePlaybackService: IVoicePlaybackService, + @IAgentHostConnectionsService agentHostConnectionsService: IAgentHostConnectionsService, + @IOpenerService openerService: IOpenerService, ) { super(); @@ -3865,6 +3892,9 @@ export class SessionsFlatList extends Disposable { markdownRendererService, hoverService, sessionsProvidersService, + this._sessionsManagementService, + agentHostConnectionsService, + openerService, agentSessionsService, voicePlaybackService, ); diff --git a/src/vs/sessions/contrib/sessions/test/browser/automationsView.fixture.ts b/src/vs/sessions/contrib/sessions/test/browser/automationsView.fixture.ts index 4c56ff844de..34ba4bae456 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/automationsView.fixture.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/automationsView.fixture.ts @@ -23,6 +23,7 @@ import { NullLogService } from '../../../../../platform/log/common/log.js'; import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { InMemoryStorageService } from '../../../../../platform/storage/common/storage.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { IAutomationDescriptor, IAutomationRun } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; import { IAutomationDialogService } from '../../../../../workbench/contrib/chat/common/automations/automationDialogService.js'; import { ChatAutomationsEnabledContext } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; @@ -196,6 +197,7 @@ function renderAutomations(ctx: ComponentFixtureContext, options: IAutomationsFi reg.defineInstance(IActionViewItemService, actionViewItemService); reg.define(IListService, ListService); reg.define(IMarkdownRendererService, MarkdownRendererService); + reg.defineInstance(IAgentHostConnectionsService, new class extends mock() { }()); reg.define(IMenuService, MenuService); reg.defineInstance(IConfigurationService, configurationService); reg.defineInstance(IContextKeyService, contextKeyService); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index 5b39fb6fd02..a0bb20b35dc 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -27,7 +27,9 @@ import { ICustomViewService } from '../../../../services/customView/browser/cust import { ISessionsListModelService } from '../../../../services/sessions/browser/sessionsListModelService.js'; import { IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { computeReorderSortChanges, groupByDate, groupByWorkspace, groupSessionsForList, ISessionSection, limitSessionsForList, SessionSectionRenderer, SessionsFlatList, SessionsList, sortSessions, SessionsGrouping, SessionsSorting } from '../../browser/views/sessionsList.js'; +import { getSessionSummaryHoverData } from '../../browser/sessionHoverContent.js'; import { createListHarness, createTestSession } from './sessionsListTestUtils.js'; import '../../browser/views/sessionsViewActions.js'; @@ -533,6 +535,26 @@ suite('Sessions - SessionsList', () => { }); }); + test('created session hover includes its creator action', () => { + const createdSession = createSession('Created', { workspaceLabel: 'Workspace' }); + const onOpen = () => { }; + const hover = getSessionSummaryHoverData( + createdSession, + new class extends mock() { + override getProvider() { return undefined; } + }, + { + title: 'Creator session', + onOpen, + }, + ); + + assert.deepStrictEqual(hover.createdBy, { + title: 'Creator session', + onOpen, + }); + }); + suite('groupSessionsForList', () => { test('shows pinned sessions in a dedicated top section', () => { diff --git a/src/vs/sessions/services/sessions/browser/sessionGroupsService.ts b/src/vs/sessions/services/sessions/browser/sessionGroupsService.ts index da12752602e..9ec6a91468a 100644 --- a/src/vs/sessions/services/sessions/browser/sessionGroupsService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionGroupsService.ts @@ -98,10 +98,13 @@ export interface ISessionGroupsService { export const ISessionGroupsService = createDecorator('sessionGroupsService'); +const EXPLICITLY_UNGROUPED_FIELD = 'explicitlyUngroupedSessionIds'; + interface ISerializedState { readonly groups: readonly ISessionGroup[]; /** sessionId -> groupId */ readonly membership: Readonly>; + readonly [EXPLICITLY_UNGROUPED_FIELD]?: readonly string[]; } export class SessionGroupsService extends Disposable implements ISessionGroupsService { @@ -116,6 +119,7 @@ export class SessionGroupsService extends Disposable implements ISessionGroupsSe private readonly _groups = new Map(); /** sessionId -> groupId */ private readonly _membership = new Map(); + private readonly _explicitlyUngroupedSessionIds = new Set(); /** * Group that the composer's in-progress new session should join once sent, @@ -142,8 +146,9 @@ export class SessionGroupsService extends Disposable implements ISessionGroupsSe this.load(); const archivedMembershipChanged = new Set(); - this.removeArchivedMembership(this.sessionsManagementService.getSessions(), archivedMembershipChanged); - if (archivedMembershipChanged.size > 0) { + const archivedStateChanged = this.removeArchivedMembership(this.sessionsManagementService.getSessions(), archivedMembershipChanged); + this.updateDefaultPlacement(this.sessionsManagementService.getSessions(), archivedMembershipChanged); + if (archivedStateChanged || archivedMembershipChanged.size > 0) { this.save(); } @@ -157,20 +162,36 @@ export class SessionGroupsService extends Disposable implements ISessionGroupsSe this._inFlightSessionGroups.delete(session.sessionId); } const changed = new Set(); - this.removeArchivedMembership(e.added, changed); - this.removeArchivedMembership(e.changed, changed); - if (changed.size > 0) { + const archivedStateChanged = this.removeArchivedMembership([...e.added, ...e.changed], changed); + this.updateDefaultPlacement(this.sessionsManagementService.getSessions(), changed); + if (archivedStateChanged || changed.size > 0) { this.save(); + } + if (changed.size > 0) { this._onDidChange.fire({ groupsChanged: false, membershipChanged: changed }); } })); this._register(this.sessionsManagementService.onDidDeleteSession(session => { - this.removeFromGroup(session.sessionId); + const membershipDeleted = this._membership.delete(session.sessionId); + const ungroupedDeleted = this._explicitlyUngroupedSessionIds.delete(session.sessionId); + if (membershipDeleted || ungroupedDeleted) { + this.save(); + } + if (membershipDeleted) { + this._onDidChange.fire({ groupsChanged: false, membershipChanged: new Set([session.sessionId]) }); + } })); this._register(this.sessionsManagementService.onDidArchiveSession(session => { - this.removeFromGroup(session.sessionId); + const membershipDeleted = this._membership.delete(session.sessionId); + const ungroupedAdded = this.markExplicitlyUngrouped(session.sessionId); + if (membershipDeleted || ungroupedAdded) { + this.save(); + } + if (membershipDeleted) { + this._onDidChange.fire({ groupsChanged: false, membershipChanged: new Set([session.sessionId]) }); + } })); // Lock the pending group onto the specific draft at send-dispatch, before @@ -216,6 +237,26 @@ export class SessionGroupsService extends Disposable implements ISessionGroupsSe })); } + /** Fills missing custom-group membership from creation provenance; explicit membership or ungrouping remains authoritative. */ + private updateDefaultPlacement(sessions: readonly ISession[], changed: Set): void { + let placed: boolean; + do { + placed = false; + for (const session of sessions) { + if (session.isArchived.get() || this._membership.has(session.sessionId) || this._explicitlyUngroupedSessionIds.has(session.sessionId)) { + continue; + } + const creatorResource = session.createdBySession?.get()?.session; + const creator = creatorResource ? this.sessionsManagementService.getSession(creatorResource) : undefined; + const creatorGroupId = creator ? this._membership.get(creator.sessionId) : undefined; + if (creatorGroupId) { + this.setMembership(session.sessionId, creatorGroupId, changed); + placed = true; + } + } + } while (placed); + } + getGroups(): ISessionGroup[] { return this.sortGroups([...this._groups.values()]); } @@ -234,6 +275,7 @@ export class SessionGroupsService extends Disposable implements ISessionGroupsSe this.setMembership(sessionId, group.id, membershipChanged); } } + this.updateDefaultPlacement(this.sessionsManagementService.getSessions(), membershipChanged); this.save(); this._onDidChange.fire({ groupsChanged: true, membershipChanged }); @@ -266,6 +308,7 @@ export class SessionGroupsService extends Disposable implements ISessionGroupsSe for (const [sessionId, gid] of this._membership) { if (gid === groupId) { this._membership.delete(sessionId); + this.markExplicitlyUngrouped(sessionId); membershipChanged.add(sessionId); } } @@ -282,6 +325,7 @@ export class SessionGroupsService extends Disposable implements ISessionGroupsSe for (const sessionId of sessionIds) { this.setMembership(sessionId, groupId, membershipChanged); } + this.updateDefaultPlacement(this.sessionsManagementService.getSessions(), membershipChanged); if (membershipChanged.size === 0) { return; } @@ -293,6 +337,7 @@ export class SessionGroupsService extends Disposable implements ISessionGroupsSe if (!this._membership.delete(sessionId)) { return; } + this.markExplicitlyUngrouped(sessionId); this.save(); this._onDidChange.fire({ groupsChanged: false, membershipChanged: new Set([sessionId]) }); } @@ -318,18 +363,30 @@ export class SessionGroupsService extends Disposable implements ISessionGroupsSe // -- Helpers -- private setMembership(sessionId: string, groupId: string, changed: Set): void { - if (this._membership.get(sessionId) !== groupId) { + if (this._explicitlyUngroupedSessionIds.delete(sessionId) || this._membership.get(sessionId) !== groupId) { this._membership.set(sessionId, groupId); changed.add(sessionId); } } - private removeArchivedMembership(sessions: readonly ISession[], changed: Set): void { + private markExplicitlyUngrouped(sessionId: string): boolean { + const size = this._explicitlyUngroupedSessionIds.size; + this._explicitlyUngroupedSessionIds.add(sessionId); + return this._explicitlyUngroupedSessionIds.size !== size; + } + + private removeArchivedMembership(sessions: readonly ISession[], changed: Set): boolean { + let stateChanged = false; for (const session of sessions) { - if (session.isArchived.get() && this._membership.delete(session.sessionId)) { - changed.add(session.sessionId); + if (session.isArchived.get()) { + if (this._membership.delete(session.sessionId)) { + changed.add(session.sessionId); + stateChanged = true; + } + stateChanged = this.markExplicitlyUngrouped(session.sessionId) || stateChanged; } } + return stateChanged; } /** @@ -368,19 +425,28 @@ export class SessionGroupsService extends Disposable implements ISessionGroupsSe } } } + const explicitlyUngroupedSessionIds = parsed[EXPLICITLY_UNGROUPED_FIELD]; + if (Array.isArray(explicitlyUngroupedSessionIds)) { + for (const sessionId of explicitlyUngroupedSessionIds) { + if (typeof sessionId === 'string') { + this._explicitlyUngroupedSessionIds.add(sessionId); + } + } + } } catch { // ignore corrupt data } } private save(): void { - if (this._groups.size === 0) { + if (this._groups.size === 0 && this._explicitlyUngroupedSessionIds.size === 0) { this.storageService.remove(SessionGroupsService.STORAGE_KEY, StorageScope.PROFILE); return; } const state: ISerializedState = { groups: [...this._groups.values()], membership: Object.fromEntries(this._membership), + [EXPLICITLY_UNGROUPED_FIELD]: [...this._explicitlyUngroupedSessionIds], }; this.storageService.store(SessionGroupsService.STORAGE_KEY, JSON.stringify(state), StorageScope.PROFILE, StorageTarget.USER); } diff --git a/src/vs/sessions/services/sessions/browser/sessionsListModelService.ts b/src/vs/sessions/services/sessions/browser/sessionsListModelService.ts index f0a711c09f3..07111040faa 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsListModelService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsListModelService.ts @@ -104,6 +104,7 @@ export class SessionsListModelService extends Disposable implements ISessionsLis private static readonly PINNED_SESSIONS_KEY = 'sessionsListControl.pinnedSessions'; private static readonly SORT_OVERRIDES_KEY = 'sessionsListControl.sortOverrides'; + private static readonly UPDATED_DEFAULT_PLACEMENTS_KEY = 'sessionsListControl.updatedDefaultPlacements'; private static readonly LEGACY_READ_SESSIONS_KEY = 'sessionsListControl.readSessions'; private static readonly READ_MIGRATION_DONE_KEY = 'sessionsListControl.readMigrationDone'; private static readonly UNREAD_DEFAULT_CUTOFF = new Date('2026-05-12T00:00:00.000Z'); @@ -113,6 +114,7 @@ export class SessionsListModelService extends Disposable implements ISessionsLis private readonly _pinnedSessionIds: Set; private readonly _sortOverrides: Record>; + private readonly _updatedDefaultPlacements: Map; private readonly _legacyReadSessionIds: Set | undefined; private readonly _migratedReadSessionIds: Set; @@ -124,10 +126,14 @@ export class SessionsListModelService extends Disposable implements ISessionsLis this._pinnedSessionIds = this.loadSet(SessionsListModelService.PINNED_SESSIONS_KEY); this._sortOverrides = this.loadSortOverrides(); + this._updatedDefaultPlacements = this.loadUpdatedDefaultPlacements(); const legacyRead = this.loadSet(SessionsListModelService.LEGACY_READ_SESSIONS_KEY); this._legacyReadSessionIds = legacyRead.size > 0 ? legacyRead : undefined; this._migratedReadSessionIds = this.loadSet(SessionsListModelService.READ_MIGRATION_DONE_KEY); + this._register(this.sessionsManagementService.onDidChangeSessions(() => this.updateDefaultPlacement(this.sessionsManagementService.getSessions()))); + this.updateDefaultPlacement(this.sessionsManagementService.getSessions()); + // Only a definitive deletion discards pin and sort state. A session // merely dropping out of the provider's list is an eviction (e.g. an // agent that cannot answer `listSessions` yet reports no sessions), and @@ -197,6 +203,84 @@ export class SessionsListModelService extends Disposable implements ISessionsLis // -- Manual sort order -- + /** Fills missing sort overrides so created sessions start beside their creator; existing overrides remain authoritative. */ + private updateDefaultPlacement(sessionsToCheck: readonly ISession[]): void { + const sessions = this.sessionsManagementService.getSessions(); + const changedSessionIds = new Set(); + let sortChanged = this.expireUpdatedDefaultPlacements(sessionsToCheck, changedSessionIds); + for (const mode of ['created', 'updated'] as const) { + for (const session of sessionsToCheck) { + if (this.ensureCreatorAdjacentSortOverride(session, mode, sessions, new Set(), changedSessionIds)) { + sortChanged = true; + } + } + } + if (sortChanged) { + this.saveSortOverrides(); + this.saveUpdatedDefaultPlacements(); + } + if (changedSessionIds.size > 0) { + this._onDidChange.fire({ + changes: [...changedSessionIds].map(sessionId => ({ sessionId, kind: SessionListModelChangeKind.Sort })), + }); + } + } + + private ensureCreatorAdjacentSortOverride(session: ISession, mode: SessionSortMode, sessions: readonly ISession[], visiting: Set, changedSessionIds: Set): boolean { + if (this._sortOverrides[mode].has(session.sessionId) || (mode === 'updated' && this._updatedDefaultPlacements.has(session.sessionId))) { + return false; + } + const creatorResource = session.createdBySession?.get()?.session; + if (!creatorResource) { + return false; + } + const creator = this.sessionsManagementService.getSession(creatorResource); + if (!creator || visiting.has(session.sessionId)) { + return false; + } + visiting.add(session.sessionId); + this.ensureCreatorAdjacentSortOverride(creator, mode, sessions, visiting, changedSessionIds); + visiting.delete(session.sessionId); + if (creator.createdBySession?.get() && !this._sortOverrides[mode].has(creator.sessionId)) { + return false; + } + + const creatorKey = this.getSortKey(creator, mode); + const sorted = sessions + .filter(candidate => candidate.sessionId !== session.sessionId) + .sort((a, b) => this.getSortKey(b, mode) - this.getSortKey(a, mode)); + const creatorIndex = sorted.findIndex(candidate => candidate.sessionId === creator.sessionId); + if (creatorIndex < 0) { + return false; + } + const below = sorted[creatorIndex + 1]; + const belowKey = below ? this.getSortKey(below, mode) : undefined; + const createdSessionKey = belowKey !== undefined && creatorKey > belowKey + ? (creatorKey + belowKey) / 2 + : creatorKey - 60_000; + this._sortOverrides[mode].set(session.sessionId, createdSessionKey); + if (mode === 'updated') { + this._updatedDefaultPlacements.set(session.sessionId, this.getNaturalSortKey(session, mode)); + } + changedSessionIds.add(session.sessionId); + return true; + } + + private expireUpdatedDefaultPlacements(sessionsToCheck: readonly ISession[], changedSessionIds: Set): boolean { + let changed = false; + for (const session of sessionsToCheck) { + const naturalKeyAtPlacement = this._updatedDefaultPlacements.get(session.sessionId); + if (naturalKeyAtPlacement === undefined || naturalKeyAtPlacement === null || naturalKeyAtPlacement === this.getNaturalSortKey(session, 'updated')) { + continue; + } + this._sortOverrides.updated.delete(session.sessionId); + this._updatedDefaultPlacements.set(session.sessionId, null); + changedSessionIds.add(session.sessionId); + changed = true; + } + return changed; + } + getNaturalSortKey(session: ISession, mode: SessionSortMode): number { return mode === 'updated' ? session.updatedAt.get().getTime() : session.createdAt.getTime(); } @@ -213,12 +297,26 @@ export class SessionsListModelService extends Disposable implements ISessionsLis applySortChanges(mode: SessionSortMode, set: ReadonlyMap, clear: Iterable): void { const map = this._sortOverrides[mode]; const changes: { sessionId: string; kind: SessionListModelChangeKind }[] = []; + let updatedDefaultPlacementChanged = false; for (const sessionId of clear) { - if (map.delete(sessionId)) { + if (mode === 'updated' && this._updatedDefaultPlacements.delete(sessionId)) { + updatedDefaultPlacementChanged = true; + } + const session = this.sessionsManagementService.getSessions().find(session => session.sessionId === sessionId); + if (session?.createdBySession?.get()) { + const naturalKey = this.getNaturalSortKey(session, mode); + if (map.get(sessionId) !== naturalKey) { + map.set(sessionId, naturalKey); + changes.push({ sessionId, kind: SessionListModelChangeKind.Sort }); + } + } else if (map.delete(sessionId)) { changes.push({ sessionId, kind: SessionListModelChangeKind.Sort }); } } for (const [sessionId, value] of set) { + if (mode === 'updated' && this._updatedDefaultPlacements.delete(sessionId)) { + updatedDefaultPlacementChanged = true; + } if (map.get(sessionId) !== value) { map.set(sessionId, value); changes.push({ sessionId, kind: SessionListModelChangeKind.Sort }); @@ -228,6 +326,9 @@ export class SessionsListModelService extends Disposable implements ISessionsLis this.saveSortOverrides(); this._onDidChange.fire({ changes }); } + if (updatedDefaultPlacementChanged) { + this.saveUpdatedDefaultPlacements(); + } } // -- Status icon -- @@ -269,6 +370,9 @@ export class SessionsListModelService extends Disposable implements ISessionsLis if (this._sortOverrides.updated.delete(session.sessionId)) { sortChanged = true; } + if (this._updatedDefaultPlacements.delete(session.sessionId)) { + this.saveUpdatedDefaultPlacements(); + } if (sortChanged) { this.saveSortOverrides(); changes.push({ sessionId: session.sessionId, kind: SessionListModelChangeKind.Sort }); @@ -337,6 +441,38 @@ export class SessionsListModelService extends Disposable implements ISessionsLis }; this.storageService.store(SessionsListModelService.SORT_OVERRIDES_KEY, JSON.stringify(serialized), StorageScope.PROFILE, StorageTarget.USER); } + + private loadUpdatedDefaultPlacements(): Map { + const result = new Map(); + const raw = this.storageService.get(SessionsListModelService.UPDATED_DEFAULT_PLACEMENTS_KEY, StorageScope.PROFILE); + if (!raw) { + return result; + } + try { + const parsed = JSON.parse(raw) as Record; + for (const [sessionId, value] of Object.entries(parsed)) { + if (typeof value === 'number' || value === null) { + result.set(sessionId, value); + } + } + } catch { + // ignore corrupt data + } + return result; + } + + private saveUpdatedDefaultPlacements(): void { + if (this._updatedDefaultPlacements.size === 0) { + this.storageService.remove(SessionsListModelService.UPDATED_DEFAULT_PLACEMENTS_KEY, StorageScope.PROFILE); + return; + } + this.storageService.store( + SessionsListModelService.UPDATED_DEFAULT_PLACEMENTS_KEY, + JSON.stringify(Object.fromEntries(this._updatedDefaultPlacements)), + StorageScope.PROFILE, + StorageTarget.USER, + ); + } } registerSingleton(ISessionsListModelService, SessionsListModelService, InstantiationType.Delayed); diff --git a/src/vs/sessions/services/sessions/browser/visibleSessions.ts b/src/vs/sessions/services/sessions/browser/visibleSessions.ts index edf720a46e4..639aec20e96 100644 --- a/src/vs/sessions/services/sessions/browser/visibleSessions.ts +++ b/src/vs/sessions/services/sessions/browser/visibleSessions.ts @@ -242,6 +242,7 @@ export class VisibleSession extends Disposable implements IActiveSession { get isQuickChat() { return this._session.isQuickChat; } get isAutomation() { return this._session.isAutomation; } get isExternal() { return this._session.isExternal; } + get createdBySession() { return this._session.createdBySession; } get title() { return this._session.title; } get updatedAt() { return this._session.updatedAt; } get status() { return this._session.status; } @@ -291,6 +292,7 @@ class ResourceOverrideSession implements ISession { get isQuickChat() { return this._session.isQuickChat; } get isAutomation() { return this._session.isAutomation; } get isExternal() { return this._session.isExternal; } + get createdBySession() { return this._session.createdBySession; } get title() { return this._session.title; } get updatedAt() { return this._session.updatedAt; } get status() { return this._session.status; } diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 1d4936469bd..45f5ac898f4 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -682,6 +682,8 @@ export interface ISession { readonly isAutomation?: IObservable; /** Whether this session was discovered in an application other than the current host. Absent means `false`. */ readonly isExternal?: IObservable; + /** Session turn that created this session, when it was created by another agent session. */ + readonly createdBySession?: IObservable; // Reactive properties @@ -727,6 +729,12 @@ export interface ISession { readonly capabilities: IObservable; } +export interface ISessionCreationReference { + readonly session: URI; + readonly chat?: URI; + readonly turnId?: string; +} + /** Returns whether any chat or session-level fallback reports file changes. */ export function sessionHasChanges(session: ISession, reader: IReader | undefined): boolean { if (session.chats.read(reader).some(chat => chat.changes.read(reader).length > 0)) { diff --git a/src/vs/sessions/services/sessions/test/browser/sessionGroupsService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionGroupsService.test.ts index 79dc1928d21..483979e97a4 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionGroupsService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionGroupsService.test.ts @@ -9,14 +9,14 @@ import { Emitter } from '../../../../../base/common/event.js'; import { constObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { IStorageService, InMemoryStorageService } from '../../../../../platform/storage/common/storage.js'; +import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { IChat, ISession, SessionStatus } from '../../common/session.js'; import { ISessionsChangeEvent, ISessionsManagementService } from '../../common/sessionsManagement.js'; import { SessionGroupsService } from '../../browser/sessionGroupsService.js'; -function createSession(id: string, isArchived = false): ISession { +function createSession(id: string, isArchived = false, creatorSession?: URI): ISession { return { sessionId: id, resource: URI.parse(`session://${id}`), @@ -25,6 +25,7 @@ function createSession(id: string, isArchived = false): ISession { icon: Codicon.account, createdAt: new Date(), workspace: observableValue(`workspace-${id}`, undefined), + createdBySession: constObservable(creatorSession ? { session: creatorSession } : undefined), title: observableValue(`title-${id}`, id), updatedAt: observableValue(`updatedAt-${id}`, new Date()), status: observableValue(`status-${id}`, SessionStatus.Completed), @@ -84,6 +85,7 @@ suite('SessionGroupsService', () => { instantiationService.stub(ISessionsManagementService, { ...mock(), getSessions: () => sessions, + getSession: resource => sessions.find(session => session.resource.toString() === resource.toString()), onDidChangeSessions: sessionsChangedEmitter.event, onWillSendRequest: willSendRequestEmitter.event, onDidStartSession: sessionStartedEmitter.event, @@ -116,6 +118,285 @@ suite('SessionGroupsService', () => { assert.deepStrictEqual(service.getSessionIdsInGroup(b.id), ['s1']); }); + test('copies the creator group once when a created session is added', () => { + const creator = createSession('creator'); + const createdSession = createSession('created', false, creator.resource); + sessions = [creator, createdSession]; + const inherited = service.createGroup('Inherited', [creator.sessionId]); + const userGroup = service.createGroup('User choice'); + + sessionsChangedEmitter.fire({ added: [createdSession], removed: [], changed: [] }); + const initialGroup = service.getGroupOfSession(createdSession.sessionId); + service.addToGroup(createdSession.sessionId, userGroup.id); + sessionsChangedEmitter.fire({ added: [], removed: [createdSession], changed: [] }); + sessionsChangedEmitter.fire({ added: [createdSession], removed: [], changed: [] }); + + assert.deepStrictEqual({ + initialGroup, + afterUserMoveAndReadd: service.getGroupOfSession(createdSession.sessionId), + }, { + initialGroup: inherited.id, + afterUserMoveAndReadd: userGroup.id, + }); + }); + + test('copies the creator group once when creation metadata arrives after add', () => { + const creator = createSession('creator'); + const createdBySession = observableValue<{ readonly session: URI } | undefined>('createdBySession', undefined); + const createdSession: ISession = { ...createSession('created'), createdBySession }; + sessions = [creator, createdSession]; + const inherited = service.createGroup('Inherited', [creator.sessionId]); + + sessionsChangedEmitter.fire({ added: [createdSession], removed: [], changed: [] }); + createdBySession.set({ session: creator.resource }, undefined); + sessionsChangedEmitter.fire({ added: [], removed: [], changed: [createdSession] }); + + assert.strictEqual(service.getGroupOfSession(createdSession.sessionId), inherited.id); + }); + + test('preserves ungrouping that happens before creation metadata arrives', () => { + const creator = createSession('creator'); + const createdBySession = observableValue<{ readonly session: URI } | undefined>('createdBySession', undefined); + const createdSession: ISession = { ...createSession('created'), createdBySession }; + sessions = [creator, createdSession]; + const inherited = service.createGroup('Inherited', [creator.sessionId]); + const temporary = service.createGroup('Temporary', [createdSession.sessionId]); + + service.removeFromGroup(createdSession.sessionId); + createdBySession.set({ session: creator.resource }, undefined); + sessionsChangedEmitter.fire({ added: [], removed: [], changed: [createdSession] }); + + assert.deepStrictEqual({ + temporaryMembers: service.getSessionIdsInGroup(temporary.id), + createdGroup: service.getGroupOfSession(createdSession.sessionId), + creatorGroup: service.getGroupOfSession(creator.sessionId), + }, { + temporaryMembers: [], + createdGroup: undefined, + creatorGroup: inherited.id, + }); + }); + + test('copies the creator group for sessions that predate service construction', () => { + const creator = createSession('creator'); + const createdSession = createSession('created', false, creator.resource); + const inherited = service.createGroup('Inherited', [creator.sessionId]); + sessions = [creator, createdSession]; + service.dispose(); + + service = disposables.add(instantiationService.createInstance(SessionGroupsService)); + + assert.strictEqual(service.getGroupOfSession(createdSession.sessionId), inherited.id); + }); + + test('inherits when the creator is grouped later', () => { + const creator = createSession('creator'); + const createdSession = createSession('created', false, creator.resource); + sessions = [creator, createdSession]; + + const inherited = service.createGroup('Inherited', [creator.sessionId]); + + assert.strictEqual(service.getGroupOfSession(createdSession.sessionId), inherited.id); + }); + + test('inherits when the creator arrives after the created session', () => { + const creator = createSession('creator'); + const createdSession = createSession('created', false, creator.resource); + sessions = [createdSession]; + const inherited = service.createGroup('Inherited', [creator.sessionId]); + sessionsChangedEmitter.fire({ added: [createdSession], removed: [], changed: [] }); + + sessions = [createdSession, creator]; + sessionsChangedEmitter.fire({ added: [creator], removed: [], changed: [] }); + + assert.strictEqual(service.getGroupOfSession(createdSession.sessionId), inherited.id); + }); + + test('initializes reversed creation chains creator-first', () => { + const root = createSession('root'); + const child = createSession('child', false, root.resource); + const grandchild = createSession('grandchild', false, child.resource); + sessions = [grandchild, child, root]; + + const inherited = service.createGroup('Inherited', [root.sessionId]); + + assert.deepStrictEqual({ + child: service.getGroupOfSession(child.sessionId), + grandchild: service.getGroupOfSession(grandchild.sessionId), + }, { + child: inherited.id, + grandchild: inherited.id, + }); + }); + + test('batches inherited chain membership changes and is idempotent', () => { + const root = createSession('root'); + const child = createSession('child', false, root.resource); + const grandchild = createSession('grandchild', false, child.resource); + const inherited = service.createGroup('Inherited', [root.sessionId]); + sessions = [grandchild, child, root]; + const events: { groupsChanged: boolean; membershipChanged: string[] }[] = []; + disposables.add(service.onDidChange(event => events.push({ + groupsChanged: event.groupsChanged, + membershipChanged: [...event.membershipChanged].sort(), + }))); + + sessionsChangedEmitter.fire({ added: [grandchild, child, root], removed: [], changed: [] }); + sessionsChangedEmitter.fire({ added: [], removed: [], changed: [grandchild, child, root] }); + + assert.deepStrictEqual({ + child: service.getGroupOfSession(child.sessionId), + grandchild: service.getGroupOfSession(grandchild.sessionId), + events, + }, { + child: inherited.id, + grandchild: inherited.id, + events: [{ + groupsChanged: false, + membershipChanged: ['child', 'grandchild'], + }], + }); + }); + + test('persists an explicitly ungrouped created session', () => { + const creator = createSession('creator'); + const createdSession = createSession('created', false, creator.resource); + sessions = [creator, createdSession]; + const inherited = service.createGroup('Inherited', [creator.sessionId]); + sessionsChangedEmitter.fire({ added: [createdSession], removed: [], changed: [] }); + assert.strictEqual(service.getGroupOfSession(createdSession.sessionId), inherited.id); + + service.removeFromGroup(createdSession.sessionId); + service.dispose(); + service = disposables.add(instantiationService.createInstance(SessionGroupsService)); + + assert.strictEqual(service.getGroupOfSession(createdSession.sessionId), undefined); + }); + + test('explicit regrouping clears the persisted ungrouped preference', () => { + const creator = createSession('creator'); + const createdSession = createSession('created', false, creator.resource); + sessions = [creator, createdSession]; + const inherited = service.createGroup('Inherited', [creator.sessionId]); + const selected = service.createGroup('Selected'); + sessionsChangedEmitter.fire({ added: [createdSession], removed: [], changed: [] }); + + service.removeFromGroup(createdSession.sessionId); + service.addToGroup(createdSession.sessionId, selected.id); + service.dispose(); + service = disposables.add(instantiationService.createInstance(SessionGroupsService)); + + assert.deepStrictEqual({ + creatorGroup: service.getGroupOfSession(creator.sessionId), + createdGroup: service.getGroupOfSession(createdSession.sessionId), + }, { + creatorGroup: inherited.id, + createdGroup: selected.id, + }); + }); + + test('deleting an inherited group leaves the created session explicitly ungrouped', () => { + const creator = createSession('creator'); + const createdSession = createSession('created', false, creator.resource); + sessions = [creator, createdSession]; + const inherited = service.createGroup('Inherited', [creator.sessionId]); + sessionsChangedEmitter.fire({ added: [createdSession], removed: [], changed: [] }); + + service.deleteGroup(inherited.id); + service.dispose(); + service = disposables.add(instantiationService.createInstance(SessionGroupsService)); + const replacement = service.createGroup('Replacement', [creator.sessionId]); + + assert.deepStrictEqual({ + creatorGroup: service.getGroupOfSession(creator.sessionId), + createdGroup: service.getGroupOfSession(createdSession.sessionId), + }, { + creatorGroup: replacement.id, + createdGroup: undefined, + }); + }); + + test('archiving an inherited session leaves it explicitly ungrouped', () => { + const creator = createSession('creator'); + const createdSession = createSession('created', false, creator.resource); + sessions = [creator, createdSession]; + const inherited = service.createGroup('Inherited', [creator.sessionId]); + sessionsChangedEmitter.fire({ added: [createdSession], removed: [], changed: [] }); + + sessionArchivedEmitter.fire(createdSession); + service.dispose(); + service = disposables.add(instantiationService.createInstance(SessionGroupsService)); + sessionsChangedEmitter.fire({ added: [], removed: [], changed: [createdSession] }); + + assert.deepStrictEqual({ + creatorGroup: service.getGroupOfSession(creator.sessionId), + createdGroup: service.getGroupOfSession(createdSession.sessionId), + }, { + creatorGroup: inherited.id, + createdGroup: undefined, + }); + }); + + test('an initially archived created session does not inherit after restoration', () => { + const creator = createSession('creator'); + const archived = createSession('created', true, creator.resource); + sessions = [creator, archived]; + const inherited = service.createGroup('Inherited', [creator.sessionId]); + sessionsChangedEmitter.fire({ added: [archived], removed: [], changed: [] }); + + const restored = createSession('created', false, creator.resource); + sessions = [creator, restored]; + service.dispose(); + service = disposables.add(instantiationService.createInstance(SessionGroupsService)); + + assert.deepStrictEqual({ + creatorGroup: service.getGroupOfSession(creator.sessionId), + restoredGroup: service.getGroupOfSession(restored.sessionId), + }, { + creatorGroup: inherited.id, + restoredGroup: undefined, + }); + }); + + test('deletion clears a persisted ungrouped preference', () => { + const creator = createSession('creator'); + const createdSession = createSession('created', false, creator.resource); + sessions = [creator, createdSession]; + const inherited = service.createGroup('Inherited', [creator.sessionId]); + sessionsChangedEmitter.fire({ added: [createdSession], removed: [], changed: [] }); + service.removeFromGroup(createdSession.sessionId); + + sessionDeletedEmitter.fire(createdSession); + const replacement = createSession('created', false, creator.resource); + sessions = [creator, replacement]; + service.dispose(); + service = disposables.add(instantiationService.createInstance(SessionGroupsService)); + + assert.strictEqual(service.getGroupOfSession(replacement.sessionId), inherited.id); + }); + + test('an ungrouped preference survives temporary provider eviction', () => { + const creator = createSession('creator'); + const createdSession = createSession('created', false, creator.resource); + sessions = [creator, createdSession]; + service.createGroup('Inherited', [creator.sessionId]); + const temporary = service.createGroup('Temporary', [createdSession.sessionId]); + service.removeFromGroup(createdSession.sessionId); + + sessions = [creator]; + sessionsChangedEmitter.fire({ added: [], removed: [createdSession], changed: [] }); + sessions = [creator, createdSession]; + sessionsChangedEmitter.fire({ added: [createdSession], removed: [], changed: [] }); + + assert.deepStrictEqual({ + createdGroup: service.getGroupOfSession(createdSession.sessionId), + temporaryMembers: service.getSessionIdsInGroup(temporary.id), + }, { + createdGroup: undefined, + temporaryMembers: [], + }); + }); + test('addToGroup adds multiple sessions in a single change event', () => { const a = service.createGroup('A'); let changeCount = 0; @@ -291,6 +572,24 @@ suite('SessionGroupsService', () => { assert.strictEqual(reloaded.getGroupOfSession('s2'), a.id); }); + test('loads pre-feature group state without explicit ungrouped data', () => { + storageService.store('sessionsListControl.groups', JSON.stringify({ + groups: [{ id: 'legacy-group', name: 'Legacy', createdAt: 1 }], + membership: { s1: 'legacy-group' }, + }), StorageScope.PROFILE, StorageTarget.USER); + + service.dispose(); + service = disposables.add(instantiationService.createInstance(SessionGroupsService)); + + assert.deepStrictEqual({ + group: service.getGroup('legacy-group')?.name, + membership: service.getGroupOfSession('s1'), + }, { + group: 'Legacy', + membership: 'legacy-group', + }); + }); + test('pending new session group binds the next started session', () => { const a = service.createGroup('A'); service.setPendingNewSessionGroup(a.id); diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsListModelService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsListModelService.test.ts index 6fdd8f37b93..691c062cecd 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsListModelService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsListModelService.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { Emitter } from '../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { constObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; @@ -16,7 +16,7 @@ import { ISessionListModelChangeEvent, SessionListModelChangeKind, SessionsListM import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { mock } from '../../../../../base/test/common/mock.js'; -function createSession(id: string, status: SessionStatus = SessionStatus.Completed, opts?: { createdAt?: Date; updatedAt?: Date }): ISession { +function createSession(id: string, status: SessionStatus = SessionStatus.Completed, opts?: { createdAt?: Date; updatedAt?: Date; createdBySession?: URI }): ISession { return { sessionId: id, resource: URI.parse(`session://${id}`), @@ -25,6 +25,7 @@ function createSession(id: string, status: SessionStatus = SessionStatus.Complet icon: Codicon.account, createdAt: opts?.createdAt ?? new Date(), workspace: observableValue(`workspace-${id}`, undefined), + createdBySession: constObservable(opts?.createdBySession ? { session: opts.createdBySession } : undefined), title: observableValue(`title-${id}`, id), updatedAt: observableValue(`updatedAt-${id}`, opts?.updatedAt ?? new Date()), status: observableValue(`status-${id}`, status), @@ -49,14 +50,21 @@ suite('SessionsListModelService', () => { let service: SessionsListModelService; let sessionsChangedEmitter: Emitter; let sessionDeletedEmitter: Emitter; + let sessions: ISession[]; + let instantiationService: TestInstantiationService; + let storageService: InMemoryStorageService; setup(() => { - const instantiationService = disposables.add(new TestInstantiationService()); - instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); + instantiationService = disposables.add(new TestInstantiationService()); + storageService = disposables.add(new InMemoryStorageService()); + instantiationService.stub(IStorageService, storageService); sessionsChangedEmitter = disposables.add(new Emitter()); sessionDeletedEmitter = disposables.add(new Emitter()); + sessions = []; instantiationService.stub(ISessionsManagementService, { ...mock(), + getSessions: () => sessions, + getSession: resource => sessions.find(session => session.resource.toString() === resource.toString()), onDidChangeSessions: sessionsChangedEmitter.event, onDidDeleteSession: sessionDeletedEmitter.event, }); @@ -172,6 +180,271 @@ suite('SessionsListModelService', () => { ]); }); + test('places a created session after its creator once and preserves later user ordering', () => { + const creator = createSession('creator', SessionStatus.Completed, { + createdAt: new Date('2024-06-03'), + updatedAt: new Date('2024-06-03'), + }); + const next = createSession('next', SessionStatus.Completed, { + createdAt: new Date('2024-06-01'), + updatedAt: new Date('2024-06-01'), + }); + const createdSession = createSession('created', SessionStatus.Completed, { + createdAt: new Date('2024-06-04'), + updatedAt: new Date('2024-06-04'), + createdBySession: creator.resource, + }); + sessions = [createdSession, creator, next]; + + sessionsChangedEmitter.fire({ added: [createdSession], removed: [], changed: [] }); + const initialCreatedKey = service.getSortKey(createdSession, 'created'); + const initialUpdatedKey = service.getSortKey(createdSession, 'updated'); + service.applySortChanges('created', new Map([[createdSession.sessionId, creator.createdAt.getTime() + 60_000]]), []); + sessionsChangedEmitter.fire({ added: [], removed: [createdSession], changed: [] }); + sessionsChangedEmitter.fire({ added: [createdSession], removed: [], changed: [] }); + + assert.deepStrictEqual({ + initialCreatedBetweenCreatorAndNext: creator.createdAt.getTime() > initialCreatedKey && initialCreatedKey > next.createdAt.getTime(), + initialUpdatedBetweenCreatorAndNext: creator.updatedAt.get().getTime() > initialUpdatedKey && initialUpdatedKey > next.updatedAt.get().getTime(), + createdAfterUserReorderAndReadd: service.getSortKey(createdSession, 'created'), + }, { + initialCreatedBetweenCreatorAndNext: true, + initialUpdatedBetweenCreatorAndNext: true, + createdAfterUserReorderAndReadd: creator.createdAt.getTime() + 60_000, + }); + }); + + test('batches default sort placement and is idempotent', () => { + const creator = createSession('creator', SessionStatus.Completed, { createdAt: new Date('2024-06-03') }); + const createdSession = createSession('created', SessionStatus.Completed, { + createdAt: new Date('2024-06-04'), + createdBySession: creator.resource, + }); + sessions = [createdSession, creator]; + const events: ISessionListModelChangeEvent[] = []; + disposables.add(service.onDidChange(event => events.push(event))); + + sessionsChangedEmitter.fire({ added: [createdSession], removed: [], changed: [] }); + sessionsChangedEmitter.fire({ added: [], removed: [], changed: [createdSession] }); + + assert.deepStrictEqual(events, [{ + changes: [{ sessionId: createdSession.sessionId, kind: SessionListModelChangeKind.Sort }], + }]); + }); + + test('updated default placement expires when the created session becomes more recent', () => { + const updatedAt = observableValue('created-updatedAt', new Date('2024-06-04')); + const creator = createSession('creator', SessionStatus.Completed, { + updatedAt: new Date('2024-06-03'), + }); + const next = createSession('next', SessionStatus.Completed, { + updatedAt: new Date('2024-06-01'), + }); + const createdSession: ISession = { + ...createSession('created', SessionStatus.Completed, { createdBySession: creator.resource }), + updatedAt, + }; + sessions = [createdSession, creator, next]; + sessionsChangedEmitter.fire({ added: [createdSession], removed: [], changed: [] }); + + updatedAt.set(new Date('2024-06-05'), undefined); + sessionsChangedEmitter.fire({ added: [], removed: [], changed: [createdSession] }); + service.dispose(); + service = disposables.add(instantiationService.createInstance(SessionsListModelService)); + + assert.deepStrictEqual({ + hasOverride: service.hasSortOverride(createdSession.sessionId, 'updated'), + sortKey: service.getSortKey(createdSession, 'updated'), + }, { + hasOverride: false, + sortKey: new Date('2024-06-05').getTime(), + }); + }); + + test('places a created session when creation metadata arrives after add', () => { + const creator = createSession('creator', SessionStatus.Completed, { createdAt: new Date('2024-06-03') }); + const next = createSession('next', SessionStatus.Completed, { createdAt: new Date('2024-06-01') }); + const createdBySession = observableValue<{ readonly session: URI } | undefined>('createdBySession', undefined); + const createdSession: ISession = { ...createSession('created', SessionStatus.Completed, { createdAt: new Date('2024-06-04') }), createdBySession }; + sessions = [createdSession, creator, next]; + + sessionsChangedEmitter.fire({ added: [createdSession], removed: [], changed: [] }); + createdBySession.set({ session: creator.resource }, undefined); + sessionsChangedEmitter.fire({ added: [], removed: [], changed: [createdSession] }); + + const createdSessionKey = service.getSortKey(createdSession, 'created'); + assert.strictEqual(creator.createdAt.getTime() > createdSessionKey && createdSessionKey > next.createdAt.getTime(), true); + }); + + test('places created sessions that predate service construction', () => { + const creator = createSession('creator', SessionStatus.Completed, { createdAt: new Date('2024-06-03') }); + const next = createSession('next', SessionStatus.Completed, { createdAt: new Date('2024-06-01') }); + const createdSession = createSession('created', SessionStatus.Completed, { + createdAt: new Date('2024-06-04'), + createdBySession: creator.resource, + }); + sessions = [createdSession, creator, next]; + service.dispose(); + + service = disposables.add(instantiationService.createInstance(SessionsListModelService)); + + const createdSessionKey = service.getSortKey(createdSession, 'created'); + assert.strictEqual(creator.createdAt.getTime() > createdSessionKey && createdSessionKey > next.createdAt.getTime(), true); + }); + + test('places a created session when its creator arrives later', () => { + const creator = createSession('creator', SessionStatus.Completed, { createdAt: new Date('2024-06-03') }); + const next = createSession('next', SessionStatus.Completed, { createdAt: new Date('2024-06-01') }); + const createdSession = createSession('created', SessionStatus.Completed, { + createdAt: new Date('2024-06-04'), + createdBySession: creator.resource, + }); + sessions = [createdSession, next]; + sessionsChangedEmitter.fire({ added: [createdSession], removed: [], changed: [] }); + + sessions = [createdSession, creator, next]; + sessionsChangedEmitter.fire({ added: [creator], removed: [], changed: [] }); + + const createdSessionKey = service.getSortKey(createdSession, 'created'); + assert.strictEqual(creator.createdAt.getTime() > createdSessionKey && createdSessionKey > next.createdAt.getTime(), true); + }); + + test('initializes reversed creation chains creator-first', () => { + const root = createSession('root', SessionStatus.Completed, { createdAt: new Date('2024-06-03') }); + const child = createSession('child', SessionStatus.Completed, { + createdAt: new Date('2024-06-04'), + createdBySession: root.resource, + }); + const grandchild = createSession('grandchild', SessionStatus.Completed, { + createdAt: new Date('2024-06-05'), + createdBySession: child.resource, + }); + sessions = [grandchild, child, root]; + service.dispose(); + + service = disposables.add(instantiationService.createInstance(SessionsListModelService)); + + assert.strictEqual( + service.getSortKey(root, 'created') > service.getSortKey(child, 'created') + && service.getSortKey(child, 'created') > service.getSortKey(grandchild, 'created'), + true, + ); + }); + + test('does not create overrides for cyclic creation provenance', () => { + const firstCreatedBy = observableValue<{ readonly session: URI } | undefined>('firstCreatedBy', undefined); + const secondCreatedBy = observableValue<{ readonly session: URI } | undefined>('secondCreatedBy', undefined); + const first: ISession = { ...createSession('first'), createdBySession: firstCreatedBy }; + const second: ISession = { ...createSession('second'), createdBySession: secondCreatedBy }; + firstCreatedBy.set({ session: second.resource }, undefined); + secondCreatedBy.set({ session: first.resource }, undefined); + sessions = [first, second]; + let changeCount = 0; + disposables.add(service.onDidChange(() => changeCount++)); + + sessionsChangedEmitter.fire({ added: [first, second], removed: [], changed: [] }); + + assert.deepStrictEqual({ + firstCreated: service.hasSortOverride(first.sessionId, 'created'), + firstUpdated: service.hasSortOverride(first.sessionId, 'updated'), + secondCreated: service.hasSortOverride(second.sessionId, 'created'), + secondUpdated: service.hasSortOverride(second.sessionId, 'updated'), + changeCount, + }, { + firstCreated: false, + firstUpdated: false, + secondCreated: false, + secondUpdated: false, + changeCount: 0, + }); + }); + + test('does not create overrides for self-referential creation provenance', () => { + const createdBySession = observableValue<{ readonly session: URI } | undefined>('createdBySession', undefined); + const session: ISession = { ...createSession('self'), createdBySession }; + createdBySession.set({ session: session.resource }, undefined); + sessions = [session]; + + sessionsChangedEmitter.fire({ added: [session], removed: [], changed: [] }); + + assert.deepStrictEqual({ + created: service.hasSortOverride(session.sessionId, 'created'), + updated: service.hasSortOverride(session.sessionId, 'updated'), + }, { + created: false, + updated: false, + }); + }); + + test('keeps an explicit natural-order placement across service reconstruction', () => { + const creator = createSession('creator', SessionStatus.Completed, { createdAt: new Date('2024-06-03') }); + const createdSession = createSession('created', SessionStatus.Completed, { + createdAt: new Date('2024-06-04'), + createdBySession: creator.resource, + }); + sessions = [createdSession, creator]; + sessionsChangedEmitter.fire({ added: [createdSession], removed: [], changed: [] }); + + service.applySortChanges('created', new Map(), [createdSession.sessionId]); + service.dispose(); + service = disposables.add(instantiationService.createInstance(SessionsListModelService)); + + assert.deepStrictEqual({ + hasOverride: service.hasSortOverride(createdSession.sessionId, 'created'), + sortKey: service.getSortKey(createdSession, 'created'), + }, { + hasOverride: true, + sortKey: createdSession.createdAt.getTime(), + }); + }); + + test('clearing a normal session override restores absence and is idempotent', () => { + const session = createSession('normal'); + sessions = [session]; + service.applySortChanges('created', new Map([[session.sessionId, 42]]), []); + let changeCount = 0; + disposables.add(service.onDidChange(() => changeCount++)); + + service.applySortChanges('created', new Map(), [session.sessionId]); + service.applySortChanges('created', new Map(), [session.sessionId]); + + assert.deepStrictEqual({ + hasOverride: service.hasSortOverride(session.sessionId, 'created'), + changeCount, + }, { + hasOverride: false, + changeCount: 1, + }); + }); + + test('preserves a persisted mode override while filling only the missing mode', () => { + const creator = createSession('creator', SessionStatus.Completed, { + createdAt: new Date('2024-06-03'), + updatedAt: new Date('2024-06-03'), + }); + const createdSession = createSession('created', SessionStatus.Completed, { + createdAt: new Date('2024-06-04'), + updatedAt: new Date('2024-06-04'), + createdBySession: creator.resource, + }); + sessions = [createdSession, creator]; + const persistedCreatedKey = 123; + storageService.store('sessionsListControl.sortOverrides', JSON.stringify({ + created: { [createdSession.sessionId]: persistedCreatedKey }, + }), StorageScope.PROFILE, StorageTarget.USER); + service.dispose(); + + service = disposables.add(instantiationService.createInstance(SessionsListModelService)); + + assert.deepStrictEqual({ + createdKey: service.getSortKey(createdSession, 'created'), + hasUpdatedOverride: service.hasSortOverride(createdSession.sessionId, 'updated'), + }, { + createdKey: persistedCreatedKey, + hasUpdatedOverride: true, + }); + }); + // -- Cleanup -- test('cleans up state when session is deleted', () => { @@ -189,6 +462,28 @@ suite('SessionsListModelService', () => { ]); }); + test('deletion removes created and updated sort overrides', () => { + const creator = createSession('creator'); + const createdSession = createSession('created', SessionStatus.Completed, { createdBySession: creator.resource }); + sessions = [creator, createdSession]; + sessionsChangedEmitter.fire({ added: [createdSession], removed: [], changed: [] }); + assert.strictEqual(service.hasSortOverride(createdSession.sessionId, 'created'), true); + assert.strictEqual(service.hasSortOverride(createdSession.sessionId, 'updated'), true); + + sessionDeletedEmitter.fire(createdSession); + service.dispose(); + sessions = [creator]; + service = disposables.add(instantiationService.createInstance(SessionsListModelService)); + + assert.deepStrictEqual({ + created: service.hasSortOverride(createdSession.sessionId, 'created'), + updated: service.hasSortOverride(createdSession.sessionId, 'updated'), + }, { + created: false, + updated: false, + }); + }); + test('pin survives a session being evicted from the provider list', () => { const session = createSession('s1'); service.pinSession(session); @@ -204,6 +499,32 @@ suite('SessionsListModelService', () => { assert.strictEqual(changeCount, 0); }); + test('sort overrides survive temporary provider eviction', () => { + const creator = createSession('creator'); + const createdSession = createSession('created', SessionStatus.Completed, { createdBySession: creator.resource }); + sessions = [creator, createdSession]; + sessionsChangedEmitter.fire({ added: [createdSession], removed: [], changed: [] }); + const createdKey = service.getSortKey(createdSession, 'created'); + const updatedKey = service.getSortKey(createdSession, 'updated'); + let changeCount = 0; + disposables.add(service.onDidChange(() => changeCount++)); + + sessions = [creator]; + sessionsChangedEmitter.fire({ added: [], removed: [createdSession], changed: [] }); + sessions = [creator, createdSession]; + sessionsChangedEmitter.fire({ added: [createdSession], removed: [], changed: [] }); + + assert.deepStrictEqual({ + createdKey: service.getSortKey(createdSession, 'created'), + updatedKey: service.getSortKey(createdSession, 'updated'), + changeCount, + }, { + createdKey, + updatedKey, + changeCount: 0, + }); + }); + test('deletion does not fire when session has no state', () => { const session = createSession('s1'); let changeCount = 0; @@ -236,7 +557,7 @@ suite('SessionsListModelService', () => { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IStorageService, storageService); - instantiationService.stub(ISessionsManagementService, { ...mock(), onDidDeleteSession: disposables.add(new Emitter()).event }); + instantiationService.stub(ISessionsManagementService, { ...mock(), getSessions: () => [], getSession: () => undefined, onDidChangeSessions: Event.None, onDidDeleteSession: disposables.add(new Emitter()).event }); const loadedService = disposables.add(instantiationService.createInstance(SessionsListModelService)); assert.strictEqual(loadedService.isSessionPinned(createSession('s1')), true); @@ -249,13 +570,39 @@ suite('SessionsListModelService', () => { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IStorageService, storageService); - instantiationService.stub(ISessionsManagementService, { ...mock(), onDidDeleteSession: disposables.add(new Emitter()).event }); + instantiationService.stub(ISessionsManagementService, { ...mock(), getSessions: () => [], getSession: () => undefined, onDidChangeSessions: Event.None, onDidDeleteSession: disposables.add(new Emitter()).event }); const loadedService = disposables.add(instantiationService.createInstance(SessionsListModelService)); // Should not throw and should return empty state assert.strictEqual(loadedService.isSessionPinned(createSession('s1')), false); }); + test('corrupt sort storage falls back to default placement', () => { + const creator = createSession('creator'); + const createdSession = createSession('created', SessionStatus.Completed, { createdBySession: creator.resource }); + const storageService = disposables.add(new InMemoryStorageService()); + storageService.store('sessionsListControl.sortOverrides', 'not-valid-json{', StorageScope.PROFILE, StorageTarget.USER); + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IStorageService, storageService); + instantiationService.stub(ISessionsManagementService, { + ...mock(), + getSessions: () => [createdSession, creator], + getSession: resource => resource.toString() === creator.resource.toString() ? creator : undefined, + onDidChangeSessions: Event.None, + onDidDeleteSession: disposables.add(new Emitter()).event, + }); + + const loadedService = disposables.add(instantiationService.createInstance(SessionsListModelService)); + + assert.deepStrictEqual({ + created: loadedService.hasSortOverride(createdSession.sessionId, 'created'), + updated: loadedService.hasSortOverride(createdSession.sessionId, 'updated'), + }, { + created: true, + updated: true, + }); + }); + // -- Legacy read-state migration -- suite('migrateLegacyReadState', () => { @@ -276,6 +623,9 @@ suite('SessionsListModelService', () => { instantiationService.stub(IStorageService, storage); instantiationService.stub(ISessionsManagementService, { ...mock(), + getSessions: () => [], + getSession: () => undefined, + onDidChangeSessions: Event.None, onDidDeleteSession: disposables.add(new Emitter()).event, markRead: async (session: ISession) => { readMarks.push(session.sessionId); }, markUnread: async (session: ISession) => { unreadMarks.push(session.sessionId); }, @@ -331,6 +681,9 @@ suite('SessionsListModelService', () => { instantiationService.stub(IStorageService, storage); instantiationService.stub(ISessionsManagementService, { ...mock(), + getSessions: () => [], + getSession: () => undefined, + onDidChangeSessions: Event.None, onDidDeleteSession: disposables.add(new Emitter()).event, markRead: async (session: ISession) => { readMarks.push(session.sessionId); }, markUnread: async (session: ISession) => { unreadMarks.push(session.sessionId); }, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 3c542196e0f..8ac5a64784f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -1424,6 +1424,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._chatErrorContext(), this._config.connection.initializeResult.get()?.terminalCommandPrefix, this._config.connection.resourceUris, + this._config.provider, )); this._logService.trace(`[AgentHost] provideChatSessionContent: converted ${sessionState.turns.length} turn(s) into ${history.length} history item(s) for ${resolvedSession.toString()}`); @@ -1459,7 +1460,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC timestamp: parseTimestamp(sessionState.activeTurn.startedAt), variableData: messageToVariableData(sessionState.activeTurn.message, this._config.connectionAuthority), isSystemInitiated: sessionState.activeTurn.message.origin.kind === MessageKind.SystemNotification, - origin: messageToRequestOrigin(resolvedSession, sessionState.activeTurn.message, this._config.agentId), + origin: messageToRequestOrigin(resolvedSession, sessionState.activeTurn.message, this._config.agentId, this._config.provider), }); history.push({ type: 'response', @@ -2312,7 +2313,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC isHidden: isMessageHiddenFromTranscript(activeTurn.message), timestamp: parseTimestamp(activeTurn.startedAt), isTerminalRequest: isTerminalCommandPrompt(activeTurn.message.text, this._config.connection.initializeResult.get()?.terminalCommandPrefix), - origin: messageToRequestOrigin(backendSession, activeTurn.message, this._config.agentId), + origin: messageToRequestOrigin(backendSession, activeTurn.message, this._config.agentId, this._config.provider), }, ); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 973de70d730..8616537339b 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -13,7 +13,7 @@ import { Schemas } from '../../../../../../base/common/network.js'; import { posix, win32 } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; -import { buildSubagentChatUri, isMessageHiddenFromTranscript, MessageKind, ToolCallCancellationReason, ToolCallContributorKind, ToolCallRiskAssessmentStatus, ToolCallStatus, TurnState, ResponsePartKind, getInlineToolInput, getToolFileEdits, getToolOutputText, getToolSubagentContent, hasReportedUsage, readUsageInfoMeta, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, type ActiveTurn, type ChatInputAnswer, type ChatInputRequest, type ICompletedToolCall, type InputRequestResponsePart, type Message, type TerminalCommandResult, type ToolCallPendingConfirmationState, type ToolCallState, type ToolResultSubagentContent, type Turn, FileEditKind, ToolResultContentType, type ToolResultContent, type UsageInfo, type UsageInfoMeta } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentChatUri, isMessageHiddenFromTranscript, MessageKind, parseChatUri, ToolCallCancellationReason, ToolCallContributorKind, ToolCallRiskAssessmentStatus, ToolCallStatus, TurnState, ResponsePartKind, getInlineToolInput, getToolFileEdits, getToolOutputText, getToolSubagentContent, hasReportedUsage, readUsageInfoMeta, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, type ActiveTurn, type ChatInputAnswer, type ChatInputRequest, type ICompletedToolCall, type InputRequestResponsePart, type Message, type TerminalCommandResult, type ToolCallPendingConfirmationState, type ToolCallState, type ToolResultSubagentContent, type Turn, FileEditKind, ToolResultContentType, type ToolResultContent, type UsageInfo, type UsageInfoMeta } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import type { ChatInputRequestWithPlanReview, IAgentHostPlanReview } from '../../../../../../platform/agentHost/common/agentHostPlanReview.js'; import { getToolKind } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { readToolCallMeta } from '../../../../../../platform/agentHost/common/meta/agentToolCallMeta.js'; @@ -27,7 +27,7 @@ import { getBrowserViewAttachmentMetadata, isBrowserViewAttachment } from '../.. import { readAgentMessageDelegationMeta } from '../../../../../../platform/agentHost/common/meta/agentMessageDelegationMeta.js'; import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, readAgentSystemNotificationMeta } from '../../../../../../platform/agentHost/common/meta/agentSystemNotificationMeta.js'; import { isViewUnreviewedCommentsTool, isAddCommentTool } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAnnotations.js'; -import { AGENT_HOST_SESSION_LINK_SCHEME, isCreateChatTool, isCreateSessionTool, isSendMessageTool, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../../../../../platform/agentHost/common/openSessionLink.js'; +import { AGENT_HOST_SESSION_LINK_SCHEME, buildOpenSessionLinkUri, isCreateChatTool, isCreateSessionTool, isSendMessageTool, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../../../../../platform/agentHost/common/openSessionLink.js'; import { parsePartialToolInputForDisplay } from '../../../../../../platform/agentHost/common/partialToolInput.js'; import { MessageAttachmentKind, type FileEdit, type MessageAttachment, type StringOrMarkdown, type TextRange } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { normalizeFileEdit } from '../../../../../../platform/agentHost/common/fileEditDiff.js'; @@ -45,7 +45,7 @@ import { ChatRequestOriginKind, type IChatRequestOrigin } from '../../../common/ import { AgentHostCompletionReferenceKind, restoreChatTranscriptContextVariableEntry, restorePasteVariableEntryFromAttachment, toAgentHostCompletionVariableEntryFromMetadata, type IAgentFeedbackVariableEntry, type IChatRequestVariableEntry, type IElementVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { type IToolConfirmationMessages, type IToolData, type IPreparedToolInvocation, type IToolResult, type IToolResultInputOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; import { MCP } from '../../../../mcp/common/modelContextProtocol.js'; -import { basename } from '../../../../../../base/common/resources.js'; +import { basename, isEqual } from '../../../../../../base/common/resources.js'; import { hasKey, type Mutable } from '../../../../../../base/common/types.js'; import { localize } from '../../../../../../nls.js'; import type { IRange } from '../../../../../../editor/common/core/range.js'; @@ -857,7 +857,7 @@ export function usageInfoToQuotas(usage: UsageInfo | undefined): IAgentHostQuota * The `lookup` callback is responsible for any session-level fallback (e.g. * `summary.model?.id` when usage hasn't reported a model yet). */ -export function turnsToHistory(backendSession: URI, turns: readonly Turn[], participantId: string, connectionAuthority: string, lookup?: TurnModelLookup, errorContext?: IChatErrorContext, terminalCommandPrefix?: string, resourceUris: IAgentHostResourceUriMapper = createAgentHostResourceUriMapper(connectionAuthority)): IChatSessionHistoryItem[] { +export function turnsToHistory(backendSession: URI, turns: readonly Turn[], participantId: string, connectionAuthority: string, lookup?: TurnModelLookup, errorContext?: IChatErrorContext, terminalCommandPrefix?: string, resourceUris: IAgentHostResourceUriMapper = createAgentHostResourceUriMapper(connectionAuthority), logicalSessionScheme: string = backendSession.scheme): IChatSessionHistoryItem[] { const history: IChatSessionHistoryItem[] = []; for (const turn of turns) { const rawModelId = turn.usage?.model; @@ -866,7 +866,7 @@ export function turnsToHistory(backendSession: URI, turns: readonly Turn[], part // Request const variableData = messageToVariableData(turn.message, connectionAuthority); - const origin = messageToRequestOrigin(backendSession, turn.message, participantId); + const origin = messageToRequestOrigin(backendSession, turn.message, participantId, logicalSessionScheme); const isSystemInitiated = turn.message.origin.kind === MessageKind.SystemNotification; // A message runs as a terminal command when it starts with the host's // advertised prefix and has a non-empty command after it (mirroring the @@ -963,9 +963,27 @@ export function turnsToHistory(backendSession: URI, turns: readonly Turn[], part return history; } -export function messageToRequestOrigin(backendSession: URI, message: Message, participantId: string): IChatRequestOrigin | undefined { +export function messageToRequestOrigin(backendSession: URI, message: Message, participantId: string, logicalSessionScheme: string = backendSession.scheme): IChatRequestOrigin | undefined { const delegation = readAgentMessageDelegationMeta(message); - if (!delegation || delegation.sourceThreadId === AgentSession.id(backendSession)) { + if (!delegation) { + return undefined; + } + if (hasKey(delegation, { sourceSession: true })) { + const sourceSession = URI.parse(delegation.sourceSession); + const logicalSourceSession = sourceSession.scheme === backendSession.scheme + ? sourceSession.with({ scheme: logicalSessionScheme }) + : sourceSession; + return { + kind: ChatRequestOriginKind.Delegation, + sourceSessionResource: URI.parse(buildOpenSessionLinkUri( + logicalSourceSession, + delegation.sourceChat ? parseChatUri(delegation.sourceChat)?.chatId : undefined, + delegation.sourceTurnId, + )), + delegationScope: isEqual(sourceSession, backendSession) ? 'chat' : 'session', + }; + } + if (delegation.sourceThreadId === AgentSession.id(backendSession)) { return undefined; } return { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/sessionSummaryHover.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/sessionSummaryHover.css index 448fbbca397..1c983646c5d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/sessionSummaryHover.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/sessionSummaryHover.css @@ -51,6 +51,26 @@ session with no pull requests shows one rule and a quick chat none. */ min-width: 0; } +.session-summary-hover-link { + width: 100%; + padding: 0; + border: none; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.session-summary-hover-link:hover { + color: var(--vscode-textLink-foreground); +} + +.session-summary-hover-link:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: var(--vscode-spacing-size20); +} + .session-summary-hover-text { min-width: 0; overflow-wrap: anywhere; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/sessionSummaryHover.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/sessionSummaryHover.ts index d7d32b38528..8ddad675e10 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/sessionSummaryHover.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/sessionSummaryHover.ts @@ -64,6 +64,11 @@ export interface ISessionSummaryHoverData { * "Claude · Local Agent Host". */ readonly providerLabels?: readonly string[]; + /** Session that created this session, when available. */ + readonly createdBy?: { + readonly title: string; + readonly onOpen: () => void; + }; } /** @@ -82,6 +87,7 @@ export class SessionSummaryHoverWidget { private readonly _title: HTMLElement; private readonly _location: HTMLElement; private readonly _pullRequests: HTMLElement; + private readonly _createdBy: HTMLElement; private readonly _provider: HTMLElement; constructor(data?: ISessionSummaryHoverData) { @@ -89,6 +95,7 @@ export class SessionSummaryHoverWidget { this._title = dom.append(this.domNode, dom.$('.session-summary-hover-title')); this._location = dom.append(this.domNode, dom.$('.session-summary-hover-section.session-summary-hover-location')); this._pullRequests = dom.append(this.domNode, dom.$('.session-summary-hover-section.session-summary-hover-pull-requests')); + this._createdBy = dom.append(this.domNode, dom.$('.session-summary-hover-section.session-summary-hover-created-by')); this._provider = dom.append(this.domNode, dom.$('.session-summary-hover-section.session-summary-hover-provider')); if (data) { this.update(data); @@ -108,6 +115,15 @@ export class SessionSummaryHoverWidget { } this._pullRequests.classList.toggle('hidden', !this._pullRequests.hasChildNodes()); + dom.clearNode(this._createdBy); + if (data.createdBy) { + const button = dom.append(this._createdBy, dom.$('button.session-summary-hover-row.session-summary-hover-link')); + button.type = 'button'; + button.onclick = data.createdBy.onOpen; + this._appendRowContent(button, Codicon.reply, localize('sessionSummaryHover.createdBy', "Created by"), data.createdBy.title); + } + this._createdBy.classList.toggle('hidden', !this._createdBy.hasChildNodes()); + dom.clearNode(this._provider); if (data.providerLabels?.length) { dom.append(this._provider, dom.$('.session-summary-hover-row', undefined, data.providerLabels.join(SEPARATOR))); @@ -156,6 +172,10 @@ export class SessionSummaryHoverWidget { */ private _appendRow(parent: HTMLElement, icon: ThemeIcon, label?: string, detail?: string): HTMLElement { const row = dom.append(parent, dom.$('.session-summary-hover-row')); + return this._appendRowContent(row, icon, label, detail); + } + + private _appendRowContent(row: HTMLElement, icon: ThemeIcon, label?: string, detail?: string): HTMLElement { const iconElement = dom.append(row, renderIcon(icon)); iconElement.classList.add('session-summary-hover-icon'); if (icon.color) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatRequestOriginPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatRequestOriginPart.ts index 5c84ddddac1..f57ff8c32cf 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatRequestOriginPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatRequestOriginPart.ts @@ -85,13 +85,24 @@ export class ChatRequestOriginPart extends Disposable { private _renderRequestOrigin(origin: IChatRequestOrigin): void { switch (origin.kind) { - case ChatRequestOriginKind.Delegation: + case ChatRequestOriginKind.Delegation: { + const isFromAnotherChat = origin.delegationScope === 'chat'; + const isFromAnotherSession = origin.delegationScope === 'session'; this._renderContent( - localize('chat.requestOrigin.delegation', "Sent by Codex from another chat"), + isFromAnotherChat + ? localize('chat.requestOrigin.delegation.chat', "Sent from another chat") + : isFromAnotherSession + ? localize('chat.requestOrigin.delegation.session', "Sent by another session") + : localize('chat.requestOrigin.delegation', "Sent by Codex from another chat"), undefined, - localize('chat.requestOrigin.delegationAriaLabel', "Sent by Codex from another chat. Select to open the source chat."), + isFromAnotherChat + ? localize('chat.requestOrigin.delegationAriaLabel.chat', "Sent from another chat. Select to open the source.") + : isFromAnotherSession + ? localize('chat.requestOrigin.delegationAriaLabel.session', "Sent by another session. Select to open the source.") + : localize('chat.requestOrigin.delegationAriaLabel', "Sent by Codex from another chat. Select to open the source chat."), ); break; + } } } diff --git a/src/vs/workbench/contrib/chat/common/chatRequestOrigin.ts b/src/vs/workbench/contrib/chat/common/chatRequestOrigin.ts index d0052068608..faf812fb00a 100644 --- a/src/vs/workbench/contrib/chat/common/chatRequestOrigin.ts +++ b/src/vs/workbench/contrib/chat/common/chatRequestOrigin.ts @@ -14,17 +14,20 @@ export const enum ChatRequestOriginKind { export interface IChatRequestOrigin { readonly kind: ChatRequestOriginKind; readonly sourceSessionResource: URI; + readonly delegationScope?: 'chat' | 'session'; } export interface ISerializableChatRequestOrigin { readonly kind: ChatRequestOriginKind; readonly sourceSessionResource: UriComponents; + readonly delegationScope?: 'chat' | 'session'; } export function serializeChatRequestOrigin(origin: IChatRequestOrigin): ISerializableChatRequestOrigin { return { kind: origin.kind, sourceSessionResource: origin.sourceSessionResource.toJSON(), + ...(origin.delegationScope ? { delegationScope: origin.delegationScope } : {}), }; } @@ -33,7 +36,11 @@ export function reviveChatRequestOrigin(origin: ISerializableChatRequestOrigin | return undefined; } const sourceSessionResource = URI.revive(origin.sourceSessionResource); - return sourceSessionResource ? { kind: origin.kind, sourceSessionResource } : undefined; + return sourceSessionResource ? { + kind: origin.kind, + sourceSessionResource, + ...(origin.delegationScope ? { delegationScope: origin.delegationScope } : {}), + } : undefined; } export interface IChatRequestOriginOpener { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 87cf520e447..2fff026eb20 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -368,6 +368,67 @@ suite('stateToProgressAdapter', () => { }); }); + test('created session annotates only its first request with the creating turn', () => { + const firstTurn = createTurn({ + id: 'turn-1', + message: { + text: 'Hello', + origin: { kind: MessageKind.User }, + _meta: toAgentMessageDelegationMeta({ + sourceSession: 'copilot:/creator', + sourceChat: 'ahp-chat://default/Y29waWxvdDovY3JlYXRvcg', + sourceTurnId: 'creating-turn', + }), + }, + }); + const history = rawTurnsToHistory( + URI.parse('copilot:/created'), + [firstTurn, createTurn({ id: 'turn-2' })], + 'agent-host-copilot', + 'local', + ); + + assert.deepStrictEqual([ + history[0].type === 'request' ? history[0].origin : undefined, + history[2].type === 'request' ? history[2].origin : undefined, + ], [{ + kind: ChatRequestOriginKind.Delegation, + sourceSessionResource: URI.parse('agent-host-session://copilot/creator?turn=creating-turn'), + delegationScope: 'session', + }, undefined]); + }); + + test('created session maps an aliased backend source to its logical provider', () => { + const turn = createTurn({ + message: { + text: 'Hello', + origin: { kind: MessageKind.User }, + _meta: toAgentMessageDelegationMeta({ + sourceSession: 'ahp-session:/creator', + sourceChat: 'ahp-chat://default/YWhwLXNlc3Npb246L2NyZWF0b3I', + }), + }, + }); + + const history = rawTurnsToHistory( + URI.parse('ahp-session:/created'), + [turn], + 'copilot', + 'remote', + undefined, + undefined, + undefined, + undefined, + 'copilot', + ); + + assert.deepStrictEqual(history[0].type === 'request' ? history[0].origin : undefined, { + kind: ChatRequestOriginKind.Delegation, + sourceSessionResource: URI.parse('agent-host-session://copilot/creator'), + delegationScope: 'session', + }); + }); + test('thread coordination tools restore deterministic target-session chips', () => { const createLink = 'agent-host-session://codex/created-thread'; const sendLink = 'agent-host-session://codex/target-thread'; diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatRequestOriginPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatRequestOriginPart.test.ts index c25f3e6ec07..a3e8512318f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatRequestOriginPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatRequestOriginPart.test.ts @@ -65,6 +65,33 @@ suite('ChatRequestOriginPart', () => { }); }); + test('distinguishes delegation from another chat in the same session', () => { + const disposables = store.add(new DisposableStore()); + const instantiationService = workbenchInstantiationService(undefined, disposables); + instantiationService.stub(IChatRequestOriginService, disposables.add(new ChatRequestOriginService())); + instantiationService.stub(IChatSideChatService, disposables.add(new ChatSideChatService())); + instantiationService.stub(IChatService, new class extends mock() { }); + instantiationService.stub(IChatWidgetService, new class extends mock() { }); + + const part = disposables.add(instantiationService.createInstance( + ChatRequestOriginPart, + URI.parse('agent-host-copilot:/session#target'), + { + kind: ChatRequestOriginKind.Delegation, + sourceSessionResource: URI.parse('agent-host-session://copilot/session?chat=source&turn=turn-1'), + delegationScope: 'chat', + }, + )); + + assert.deepStrictEqual({ + text: part.domNode.textContent, + ariaLabel: part.domNode.getAttribute('aria-label'), + }, { + text: 'Sent from another chat', + ariaLabel: 'Sent from another chat. Select to open the source.', + }); + }); + test('preserves side chat source presentation and navigation', async () => { const disposables = store.add(new DisposableStore()); const instantiationService = workbenchInstantiationService(undefined, disposables); diff --git a/src/vs/workbench/contrib/chat/test/common/chatRequestOrigin.test.ts b/src/vs/workbench/contrib/chat/test/common/chatRequestOrigin.test.ts index 826892a4c31..baac4535053 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatRequestOrigin.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatRequestOrigin.test.ts @@ -17,7 +17,15 @@ suite('ChatRequestOrigin', () => { }; test('serializes and revives source session resources', () => { - assert.deepStrictEqual(reviveChatRequestOrigin(serializeChatRequestOrigin(origin)), origin); + const scopedDelegation = { + kind: ChatRequestOriginKind.Delegation, + sourceSessionResource: URI.parse('agent-host-session://copilot/source?turn=turn-1'), + delegationScope: 'session' as const, + }; + assert.deepStrictEqual([ + reviveChatRequestOrigin(serializeChatRequestOrigin(origin)), + reviveChatRequestOrigin(serializeChatRequestOrigin(scopedDelegation)), + ], [origin, scopedDelegation]); }); test('opens with the first provider that handles the origin', async () => { diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/blockedSessionsList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/blockedSessionsList.fixture.ts index 40bbdd24ca6..323e662122b 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/blockedSessionsList.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/blockedSessionsList.fixture.ts @@ -12,6 +12,7 @@ import { IMarkdownString, MarkdownString } from '../../../../../base/common/html import { mock } from '../../../../../base/test/common/mock.js'; import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { IListService, ListService } from '../../../../../platform/list/browser/listService.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { EditorMarkdownCodeBlockRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/editorMarkdownCodeBlockRenderer.js'; @@ -235,6 +236,7 @@ function renderBlockedList(ctx: ComponentFixtureContext, sessions: readonly ISes registerWorkbenchServices(reg); reg.define(IListService, ListService); reg.define(IMarkdownRendererService, MarkdownRendererService); + reg.defineInstance(IAgentHostConnectionsService, new class extends mock() { }()); // `SessionsFlatList` creates an `AgentSessionApprovalModel` (reads // `IChatService.chatModels`) and observes each session through the // agent-sessions model. Both are stubbed to no-ops for the fixture. diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts index 0de365fcfc6..0111ce26c47 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts @@ -13,6 +13,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { IListService, ListService } from '../../../../../platform/list/browser/listService.js'; import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; // eslint-disable-next-line local/code-import-patterns import { IAgentHostFilterService } from '../../../../../sessions/services/agentHostFilter/common/agentHostFilter.js'; @@ -120,6 +121,7 @@ function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOption registerWorkbenchServices(reg); reg.define(IListService, ListService); reg.define(IMarkdownRendererService, MarkdownRendererService); + reg.defineInstance(IAgentHostConnectionsService, new class extends mock() { }()); reg.defineInstance(IChatService, new class extends mock() { override readonly chatModels: IObservable> = constObservable([]); }()); From 153573d6d42f84ac8626b45321098e1740f98068 Mon Sep 17 00:00:00 2001 From: TylerLeonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:45:06 -0700 Subject: [PATCH 006/116] sessions: add chat background management actions (#332569) Expose layout selection and current-theme image clearing from the Agents chat context menu and Command Palette. Keep action visibility reactive to the configured theme image and derive layout IDs from the style map so Settings and picker metadata cannot drift. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/common/contextkeys.ts | 1 + .../contrib/chat/browser/chat.contribution.ts | 159 +++++++++++++++--- .../browser/sessionsChatAccessibilityHelp.ts | 2 +- .../browser/chatBackgroundService.ts | 67 ++++---- .../browser/chatBackgroundService.test.ts | 30 +++- 5 files changed, 195 insertions(+), 64 deletions(-) diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index 19c2466248f..c199e605d7a 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -45,6 +45,7 @@ export const SessionHasPullRequestContext = new RawContextKey('sessionH export const SessionHasIssuesContext = new RawContextKey('sessionHasIssues', false, localize('sessionHasIssues', "Whether the session view's session references at least one GitHub issue")); export const SessionHasWorkspaceContext = new RawContextKey('sessionHasWorkspace', false, localize('sessionHasWorkspace', "Whether the session view's session has an associated workspace folder")); export const SessionsChatBackgroundAvailableContext = new RawContextKey('sessionsChatBackgroundAvailable', false, localize('sessionsChatBackgroundAvailable', "Whether chat background customization is available for the current color theme")); +export const SessionsChatBackgroundImageConfiguredContext = new RawContextKey('sessionsChatBackgroundImageConfigured', false, localize('sessionsChatBackgroundImageConfigured', "Whether a chat background image is configured for the current color theme")); export const IsQuickChatSessionContext = new RawContextKey('isQuickChatSession', false, localize('isQuickChatSession', "Whether the session in scope is a workspace-less quick chat")); //#endregion diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index 9b3562108d0..6cb6397bce9 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -12,6 +12,7 @@ import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/c import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ISessionsManagementService, inheritableSessionTarget } from '../../../services/sessions/common/sessionsManagement.js'; @@ -45,17 +46,75 @@ import '../../sessions/browser/mobile/mobileOverlayContribution.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { EditorAreaFocusContext, IsSessionsWindowContext, SideBarVisibleContext } from '../../../../workbench/common/contextkeys.js'; import { NEW_SESSION_ACTION_ID } from '../common/constants.js'; -import { SessionsChatBackgroundAvailableContext, SessionsTitleBarNewSessionEnabledContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { SessionsChatBackgroundAvailableContext, SessionsChatBackgroundImageConfiguredContext, SessionsTitleBarNewSessionEnabledContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { Menus } from '../../../browser/menus.js'; import { ISessionsChatViewStateService, SessionsChatViewStateService } from './chatViewStateService.js'; import { SessionsChatResponseFileChangesService } from './sessionTurnChanges.js'; import { IChatResponseFileChangesService } from '../../../../workbench/contrib/chat/browser/chatResponseFileChangesService.js'; import { SessionsChatPetAchievementContribution } from './chatPetAchievements.js'; -import { AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING, AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING, chatBackgroundImageLayoutValues, ISessionsChatBackgroundService, SessionsChatBackgroundService } from '../../../services/chatBackground/browser/chatBackgroundService.js'; +import { AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING, AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING, chatBackgroundImageLayoutValues, ChatBackgroundImageLayout, ISessionsChatBackgroundService, SessionsChatBackgroundService } from '../../../services/chatBackground/browser/chatBackgroundService.js'; const CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_COMMAND_ID = 'workbench.action.chat.changeAgentSessionsBackground'; +const CLEAR_AGENT_SESSIONS_CHAT_BACKGROUND_COMMAND_ID = 'workbench.action.chat.clearAgentSessionsBackground'; +const CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_LAYOUT_COMMAND_ID = 'workbench.action.chat.changeAgentSessionsBackgroundLayout'; const CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN = ContextKeyExpr.and(IsSessionsWindowContext, SessionsChatBackgroundAvailableContext); +const CLEAR_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN = ContextKeyExpr.and(CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN, SessionsChatBackgroundImageConfiguredContext); +interface IChatBackgroundImageLayoutMetadata extends IQuickPickItem { + readonly detail: string; +} + +const chatBackgroundImageLayoutMetadata: Record = { + repeat: { + label: localize('chat.agentSessions.backgroundImageLayout.repeat.label', "Repeat"), + detail: localize('chat.agentSessions.backgroundImageLayout.repeat.description', "Repeats the image at its original size until it fills the chat background."), + }, + stretch: { + label: localize('chat.agentSessions.backgroundImageLayout.stretch.label', "Stretch"), + detail: localize('chat.agentSessions.backgroundImageLayout.stretch.description', "Stretches the image to fill the chat background."), + }, + center: { + label: localize('chat.agentSessions.backgroundImageLayout.center.label', "Center"), + detail: localize('chat.agentSessions.backgroundImageLayout.center.description', "Shows the image at its original size in the center."), + }, + top: { + label: localize('chat.agentSessions.backgroundImageLayout.top.label', "Top"), + detail: localize('chat.agentSessions.backgroundImageLayout.top.description', "Shows the image at its original size at the top center."), + }, + 'top-right': { + label: localize('chat.agentSessions.backgroundImageLayout.topRight.label', "Top Right"), + detail: localize('chat.agentSessions.backgroundImageLayout.topRight.description', "Shows the image at its original size in the top right."), + }, + 'top-left': { + label: localize('chat.agentSessions.backgroundImageLayout.topLeft.label', "Top Left"), + detail: localize('chat.agentSessions.backgroundImageLayout.topLeft.description', "Shows the image at its original size in the top left."), + }, + bottom: { + label: localize('chat.agentSessions.backgroundImageLayout.bottom.label', "Bottom"), + detail: localize('chat.agentSessions.backgroundImageLayout.bottom.description', "Shows the image at its original size at the bottom center."), + }, + 'bottom-right': { + label: localize('chat.agentSessions.backgroundImageLayout.bottomRight.label', "Bottom Right"), + detail: localize('chat.agentSessions.backgroundImageLayout.bottomRight.description', "Shows the image at its original size in the bottom right."), + }, + 'bottom-left': { + label: localize('chat.agentSessions.backgroundImageLayout.bottomLeft.label', "Bottom Left"), + detail: localize('chat.agentSessions.backgroundImageLayout.bottomLeft.description', "Shows the image at its original size in the bottom left."), + }, + left: { + label: localize('chat.agentSessions.backgroundImageLayout.left.label', "Left"), + detail: localize('chat.agentSessions.backgroundImageLayout.left.description', "Shows the image at its original size at the center left."), + }, + right: { + label: localize('chat.agentSessions.backgroundImageLayout.right.label', "Right"), + detail: localize('chat.agentSessions.backgroundImageLayout.right.description', "Shows the image at its original size at the center right."), + }, +}; + +const chatBackgroundImageLayoutItems = chatBackgroundImageLayoutValues.map(layout => ({ + layout, + ...chatBackgroundImageLayoutMetadata[layout], +})); class NewChatInSessionsWindowAction extends Action2 { @@ -132,6 +191,7 @@ class ChangeChatBackgroundAction extends Action2 { }, { id: Menus.SessionChatBackgroundContext, group: 'navigation', + order: 1, when: SessionsChatBackgroundAvailableContext, }], }); @@ -164,6 +224,73 @@ class ChangeChatBackgroundAction extends Action2 { registerAction2(ChangeChatBackgroundAction); +class ChangeChatBackgroundLayoutAction extends Action2 { + + constructor() { + super({ + id: CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_LAYOUT_COMMAND_ID, + title: localize2('chat.agentSessions.changeBackgroundLayout', "Change Background Layout..."), + category: CHAT_CATEGORY, + precondition: CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN, + menu: [{ + id: MenuId.CommandPalette, + when: CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN, + }, { + id: Menus.SessionChatBackgroundContext, + group: 'navigation', + order: 2, + when: SessionsChatBackgroundAvailableContext, + }], + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const backgroundService = accessor.get(ISessionsChatBackgroundService); + const currentLayout = backgroundService.getBackgroundImageLayout(); + const selected = await accessor.get(IQuickInputService).pick(chatBackgroundImageLayoutItems, { + title: localize('chat.agentSessions.changeBackgroundLayout.title', "Change Chat Background Layout"), + placeHolder: localize('chat.agentSessions.changeBackgroundLayout.placeholder', "Select how the background image is displayed"), + activeItem: chatBackgroundImageLayoutItems.find(item => item.layout === currentLayout), + }); + if (!selected || selected.layout === currentLayout) { + return; + } + + await backgroundService.setBackgroundImageLayout(selected.layout); + status(localize('chat.agentSessions.changeBackgroundLayout.changed', "Chat background layout changed to {0}.", selected.label)); + } +} + +registerAction2(ChangeChatBackgroundLayoutAction); + +class ClearChatBackgroundAction extends Action2 { + + constructor() { + super({ + id: CLEAR_AGENT_SESSIONS_CHAT_BACKGROUND_COMMAND_ID, + title: localize2('chat.agentSessions.clearBackground', "Clear Background Image"), + category: CHAT_CATEGORY, + precondition: CLEAR_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN, + menu: [{ + id: MenuId.CommandPalette, + when: CLEAR_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN, + }, { + id: Menus.SessionChatBackgroundContext, + group: 'navigation', + order: 3, + when: ContextKeyExpr.and(SessionsChatBackgroundAvailableContext, SessionsChatBackgroundImageConfiguredContext), + }], + }); + } + + override async run(accessor: ServicesAccessor): Promise { + await accessor.get(ISessionsChatBackgroundService).clearBackgroundImage(); + status(localize('chat.agentSessions.clearBackground.cleared', "Chat background image cleared.")); + } +} + +registerAction2(ClearChatBackgroundAction); + // register actions registerAction2(BranchChatSessionAction); @@ -224,32 +351,8 @@ Registry.as(ConfigurationExtensions.Configuration).regis [AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING]: { type: 'string', enum: [...chatBackgroundImageLayoutValues], - enumItemLabels: [ - localize('chat.agentSessions.backgroundImageLayout.repeat.label', "Repeat"), - localize('chat.agentSessions.backgroundImageLayout.stretch.label', "Stretch"), - localize('chat.agentSessions.backgroundImageLayout.center.label', "Center"), - localize('chat.agentSessions.backgroundImageLayout.top.label', "Top"), - localize('chat.agentSessions.backgroundImageLayout.topRight.label', "Top Right"), - localize('chat.agentSessions.backgroundImageLayout.topLeft.label', "Top Left"), - localize('chat.agentSessions.backgroundImageLayout.bottom.label', "Bottom"), - localize('chat.agentSessions.backgroundImageLayout.bottomRight.label', "Bottom Right"), - localize('chat.agentSessions.backgroundImageLayout.bottomLeft.label', "Bottom Left"), - localize('chat.agentSessions.backgroundImageLayout.left.label', "Left"), - localize('chat.agentSessions.backgroundImageLayout.right.label', "Right"), - ], - enumDescriptions: [ - localize('chat.agentSessions.backgroundImageLayout.repeat.description', "Repeats the image at its original size until it fills the chat background."), - localize('chat.agentSessions.backgroundImageLayout.stretch.description', "Stretches the image to fill the chat background."), - localize('chat.agentSessions.backgroundImageLayout.center.description', "Shows the image at its original size in the center."), - localize('chat.agentSessions.backgroundImageLayout.top.description', "Shows the image at its original size at the top center."), - localize('chat.agentSessions.backgroundImageLayout.topRight.description', "Shows the image at its original size in the top right."), - localize('chat.agentSessions.backgroundImageLayout.topLeft.description', "Shows the image at its original size in the top left."), - localize('chat.agentSessions.backgroundImageLayout.bottom.description', "Shows the image at its original size at the bottom center."), - localize('chat.agentSessions.backgroundImageLayout.bottomRight.description', "Shows the image at its original size in the bottom right."), - localize('chat.agentSessions.backgroundImageLayout.bottomLeft.description', "Shows the image at its original size in the bottom left."), - localize('chat.agentSessions.backgroundImageLayout.left.description', "Shows the image at its original size at the center left."), - localize('chat.agentSessions.backgroundImageLayout.right.description', "Shows the image at its original size at the center right."), - ], + enumItemLabels: chatBackgroundImageLayoutItems.map(item => item.label), + enumDescriptions: chatBackgroundImageLayoutItems.map(item => item.detail), default: 'repeat', scope: ConfigurationScope.APPLICATION, markdownDescription: localize('chat.agentSessions.backgroundImageLayout', "Controls how the dark and light chat background images are laid out in the Agents Window."), diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index e80c56ce86f..087ef47c355 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -50,7 +50,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.quickChat', "To start a workspace-less quick chat, use the New Quick Chat command{0} or the plus button on the Chats section in the sessions list. A quick chat has no workspace, so the workspace picker does not apply and the Toggle Side Panel command is disabled.", '')); content.push(localize('sessionsChat.mobileConfig', "On mobile, the mode and model pickers appear as tappable chips below the input. Tap a chip to open a bottom sheet where you can change the selection.")); content.push(localize('sessionsChat.history', "Use up and down arrows to navigate your request history in the input box.")); - content.push(localize('sessionsChat.background', "Outside high contrast themes, use the Change Background command to choose an image behind chat content for the current dark or light color theme. You can also right-click empty chat space and choose Change Background. The Chat Background Image Layout setting controls whether the image repeats, stretches, or appears at an edge or corner. Background customization is unavailable while a high contrast theme is active.")); + content.push(localize('sessionsChat.background', "Outside high contrast themes, use the Change Background command to choose an image behind chat content for the current dark or light color theme. Use Change Background Layout to choose whether the image repeats, stretches, or appears at an edge or corner. Use Clear Background Image to remove the image for the current color theme. These commands are available from the Command Palette and by right-clicking empty chat space. Clear Background Image is shown only when the current color theme has an image. Background customization is unavailable while a high contrast theme is active.")); content.push(localize('sessionsChat.vscodePet', "Use the checked Pet item in the new-session view context menu, or type /vscode-pet, to show or hide the VS Code pet above the input. Drag it horizontally to reposition it, or use Tab to focus it and the left and right arrow keys to move it. Press Enter or Space to show it some love.")); content.push(localize('sessionsChat.vscodePetAchievements', "When the pet is enabled, the user account menu lists unlocked achievement badges before locked badges and provides a View Achievements button. A gold star on the pet announces a newly unlocked achievement; activate the pet while the star is visible to open Achievements.")); content.push(localize('sessionsChat.aquariumAction', "To show or hide the aquarium action on the new-session view, use the checked Aquarium item in the context menu outside the composer, or run the Toggle Aquarium Action Visibility command.")); diff --git a/src/vs/sessions/services/chatBackground/browser/chatBackgroundService.ts b/src/vs/sessions/services/chatBackground/browser/chatBackgroundService.ts index 4b81d79a530..e4bd01633ed 100644 --- a/src/vs/sessions/services/chatBackground/browser/chatBackgroundService.ts +++ b/src/vs/sessions/services/chatBackground/browser/chatBackgroundService.ts @@ -14,28 +14,12 @@ import { IContextKeyService } from '../../../../platform/contextkey/common/conte import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { ColorScheme, isDark, isHighContrast } from '../../../../platform/theme/common/theme.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; -import { SessionsChatBackgroundAvailableContext } from '../../../common/contextkeys.js'; +import { SessionsChatBackgroundAvailableContext, SessionsChatBackgroundImageConfiguredContext } from '../../../common/contextkeys.js'; export const AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING = 'chat.agentSessions.preferredDarkBackgroundImage'; export const AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING = 'chat.agentSessions.preferredLightBackgroundImage'; export const AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING = 'chat.agentSessions.backgroundImageLayout'; -export const chatBackgroundImageLayoutValues = [ - 'repeat', - 'stretch', - 'center', - 'top', - 'top-right', - 'top-left', - 'bottom', - 'bottom-right', - 'bottom-left', - 'left', - 'right', -] as const; - -export type ChatBackgroundImageLayout = typeof chatBackgroundImageLayoutValues[number]; - export interface ISessionsChatBackground { readonly backgroundImage: string; readonly backgroundRepeat: string; @@ -43,7 +27,7 @@ export interface ISessionsChatBackground { readonly backgroundPosition: string; } -const backgroundImageStyles: Record> = { +const backgroundImageStyles = { repeat: { backgroundRepeat: 'repeat', backgroundSize: 'auto', backgroundPosition: 'left top' }, stretch: { backgroundRepeat: 'no-repeat', backgroundSize: '100% 100%', backgroundPosition: 'center center' }, center: { backgroundRepeat: 'no-repeat', backgroundSize: 'auto', backgroundPosition: 'center center' }, @@ -55,7 +39,11 @@ const backgroundImageStyles: Record>; + +export type ChatBackgroundImageLayout = keyof typeof backgroundImageStyles; + +export const chatBackgroundImageLayoutValues = Object.keys(backgroundImageStyles) as ChatBackgroundImageLayout[]; export const ISessionsChatBackgroundService = createDecorator('sessionsChatBackgroundService'); @@ -65,7 +53,10 @@ export interface ISessionsChatBackgroundService { readonly onDidChangeBackground: Event; getBackground(): ISessionsChatBackground | undefined; getConfiguredBackgroundImage(): URI | undefined; + getBackgroundImageLayout(): ChatBackgroundImageLayout; setBackgroundImage(image: URI): Promise; + clearBackgroundImage(): Promise; + setBackgroundImageLayout(layout: ChatBackgroundImageLayout): Promise; } export class SessionsChatBackgroundService extends Disposable implements ISessionsChatBackgroundService { @@ -82,18 +73,27 @@ export class SessionsChatBackgroundService extends Disposable implements ISessio super(); const backgroundAvailableContext = SessionsChatBackgroundAvailableContext.bindTo(contextKeyService); - backgroundAvailableContext.set(!isHighContrast(this.themeService.getColorTheme().type)); + const backgroundImageConfiguredContext = SessionsChatBackgroundImageConfiguredContext.bindTo(contextKeyService); + const updateContextKeys = () => { + backgroundAvailableContext.set(!isHighContrast(this.themeService.getColorTheme().type)); + backgroundImageConfiguredContext.set(!!this.getConfiguredBackgroundImage()); + }; + updateContextKeys(); this._register(this.configurationService.onDidChangeConfiguration(event => { + const backgroundImageChanged = event.affectsConfiguration(AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING) + || event.affectsConfiguration(AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING); if ( - event.affectsConfiguration(AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING) - || event.affectsConfiguration(AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING) + backgroundImageChanged || event.affectsConfiguration(AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING) ) { + if (backgroundImageChanged) { + updateContextKeys(); + } this._onDidChangeBackground.fire(); } })); - this._register(this.themeService.onDidColorThemeChange(theme => { - backgroundAvailableContext.set(!isHighContrast(theme.type)); + this._register(this.themeService.onDidColorThemeChange(() => { + updateContextKeys(); this._onDidChangeBackground.fire(); })); } @@ -119,19 +119,28 @@ export class SessionsChatBackgroundService extends Disposable implements ISessio await this.configurationService.updateValue(setting, image.toString(), ConfigurationTarget.USER); } - private getBackgroundImageSetting(colorScheme: ColorScheme): string { - return isDark(colorScheme) - ? AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING - : AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING; + async clearBackgroundImage(): Promise { + const setting = this.getBackgroundImageSetting(this.themeService.getColorTheme().type); + await this.configurationService.updateValue(setting, undefined, ConfigurationTarget.USER); } - private getBackgroundImageLayout(): ChatBackgroundImageLayout { + getBackgroundImageLayout(): ChatBackgroundImageLayout { const value = this.configurationService.getValue(AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING); return chatBackgroundImageLayoutValues.includes(value as ChatBackgroundImageLayout) ? value as ChatBackgroundImageLayout : 'repeat'; } + async setBackgroundImageLayout(layout: ChatBackgroundImageLayout): Promise { + await this.configurationService.updateValue(AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, layout, ConfigurationTarget.APPLICATION); + } + + private getBackgroundImageSetting(colorScheme: ColorScheme): string { + return isDark(colorScheme) + ? AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING + : AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING; + } + private resolveBackgroundImage(value: string | undefined): URI | undefined { const candidate = value?.trim(); if (!candidate) { diff --git a/src/vs/sessions/services/chatBackground/test/browser/chatBackgroundService.test.ts b/src/vs/sessions/services/chatBackground/test/browser/chatBackgroundService.test.ts index 56d47dc1498..91694ad5556 100644 --- a/src/vs/sessions/services/chatBackground/test/browser/chatBackgroundService.test.ts +++ b/src/vs/sessions/services/chatBackground/test/browser/chatBackgroundService.test.ts @@ -12,7 +12,7 @@ import { TestConfigurationService } from '../../../../../platform/configuration/ import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { ColorScheme } from '../../../../../platform/theme/common/theme.js'; import { TestColorTheme, TestThemeService } from '../../../../../platform/theme/test/common/testThemeService.js'; -import { SessionsChatBackgroundAvailableContext } from '../../../../common/contextkeys.js'; +import { SessionsChatBackgroundAvailableContext, SessionsChatBackgroundImageConfiguredContext } from '../../../../common/contextkeys.js'; import { AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING, AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING, chatBackgroundImageLayoutValues, ChatBackgroundImageLayout, ISessionsChatBackground, SessionsChatBackgroundService } from '../../browser/chatBackgroundService.js'; class CapturingConfigurationService extends TestConfigurationService { @@ -38,14 +38,17 @@ suite('Sessions Chat Background Service', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); test('does not return a background without a configured image', () => { - const service = disposables.add(new SessionsChatBackgroundService(new TestConfigurationService(), new TestThemeService(), disposables.add(new MockContextKeyService()))); + const contextKeyService = disposables.add(new MockContextKeyService()); + const service = disposables.add(new SessionsChatBackgroundService(new TestConfigurationService(), new TestThemeService(), contextKeyService)); assert.deepStrictEqual({ background: service.getBackground(), image: service.getConfiguredBackgroundImage(), + configured: contextKeyService.getContextKeyValue(SessionsChatBackgroundImageConfiguredContext.key), }, { background: undefined, image: undefined, + configured: false, }); }); @@ -69,6 +72,7 @@ suite('Sessions Chat Background Service', () => { size: darkBackground?.backgroundSize, position: darkBackground?.backgroundPosition, available: contextKeyService.getContextKeyValue(SessionsChatBackgroundAvailableContext.key), + configured: contextKeyService.getContextKeyValue(SessionsChatBackgroundImageConfiguredContext.key), }; themeService.setTheme(new TestColorTheme({}, ColorScheme.LIGHT)); const lightBackground = service.getBackground(); @@ -79,11 +83,13 @@ suite('Sessions Chat Background Service', () => { size: lightBackground?.backgroundSize, position: lightBackground?.backgroundPosition, available: contextKeyService.getContextKeyValue(SessionsChatBackgroundAvailableContext.key), + configured: contextKeyService.getContextKeyValue(SessionsChatBackgroundImageConfiguredContext.key), }; themeService.setTheme(new TestColorTheme({}, ColorScheme.HIGH_CONTRAST_DARK)); const highContrast = { background: service.getBackground(), available: contextKeyService.getContextKeyValue(SessionsChatBackgroundAvailableContext.key), + configured: contextKeyService.getContextKeyValue(SessionsChatBackgroundImageConfiguredContext.key), }; themeService.setTheme(new TestColorTheme({}, ColorScheme.DARK)); const restoredAvailability = contextKeyService.getContextKeyValue(SessionsChatBackgroundAvailableContext.key); @@ -95,13 +101,15 @@ suite('Sessions Chat Background Service', () => { light, highContrast, unsupportedUri: service.getBackground(), + unsupportedConfigured: contextKeyService.getContextKeyValue(SessionsChatBackgroundImageConfiguredContext.key), restoredAvailability, changes, }, { - dark: { image: true, cssImage: true, repeat: 'no-repeat', size: 'auto', position: 'center center', available: true }, - light: { image: true, cssImage: true, repeat: 'no-repeat', size: 'auto', position: 'center center', available: true }, - highContrast: { background: undefined, available: false }, + dark: { image: true, cssImage: true, repeat: 'no-repeat', size: 'auto', position: 'center center', available: true, configured: true }, + light: { image: true, cssImage: true, repeat: 'no-repeat', size: 'auto', position: 'center center', available: true, configured: true }, + highContrast: { background: undefined, available: false, configured: true }, unsupportedUri: undefined, + unsupportedConfigured: false, restoredAvailability: true, changes: 4, }); @@ -143,24 +151,34 @@ suite('Sessions Chat Background Service', () => { }); }); - test('stores an image for the active color theme', async () => { + test('updates the image for the active color theme and the shared layout', async () => { const image = URI.file('/textures/kirby.png'); const configurationService = new CapturingConfigurationService(); const themeService = new TestThemeService(); const service = disposables.add(new SessionsChatBackgroundService(configurationService, themeService, disposables.add(new MockContextKeyService()))); await service.setBackgroundImage(image); + await service.clearBackgroundImage(); themeService.setTheme(new TestColorTheme({}, ColorScheme.LIGHT)); await service.setBackgroundImage(image); + await service.setBackgroundImageLayout('bottom-right'); assert.deepStrictEqual(configurationService.updates, [{ key: AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING, value: image.toString(), target: ConfigurationTarget.USER, + }, { + key: AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING, + value: undefined, + target: ConfigurationTarget.USER, }, { key: AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING, value: image.toString(), target: ConfigurationTarget.USER, + }, { + key: AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, + value: 'bottom-right', + target: ConfigurationTarget.APPLICATION, }]); }); }); From 293593cce438781a8093ad49dd221cf50dcd0d7a Mon Sep 17 00:00:00 2001 From: Anthony Kim <62267334+anthonykim1@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:45:15 -1000 Subject: [PATCH 007/116] Keep terminal task icon when holding Alt (#332372) Preserve the active terminal label's primary icon while retaining the alternate Split command semantics and Alt-click behavior.\n\nFixes #332359\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/workbench/contrib/terminal/browser/terminalView.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/terminal/browser/terminalView.ts b/src/vs/workbench/contrib/terminal/browser/terminalView.ts index 9a745727415..d845f788366 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalView.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalView.ts @@ -515,7 +515,8 @@ class SingleTerminalTabActionViewItem extends MenuEntryActionViewItem { } } label.style.color = colorStyle; - dom.reset(label, ...renderLabelWithIcons(this._instantiationService.invokeFunction(getSingleTabLabel, instance, this._terminaConfigurationService.config.tabs.separator, ThemeIcon.isThemeIcon(this._commandAction.item.icon) ? this._commandAction.item.icon : undefined))); + const primaryActionIcon = this._menuItemAction.item.icon; + dom.reset(label, ...renderLabelWithIcons(this._instantiationService.invokeFunction(getSingleTabLabel, instance, this._terminaConfigurationService.config.tabs.separator, ThemeIcon.isThemeIcon(primaryActionIcon) ? primaryActionIcon : undefined))); if (this._altCommand) { label.classList.remove(this._altCommand); @@ -540,7 +541,7 @@ class SingleTerminalTabActionViewItem extends MenuEntryActionViewItem { this._class = uriClasses?.[0]; label.classList.add(...uriClasses); } - if (this._commandAction.item.icon) { + if (primaryActionIcon) { this._altCommand = `alt-command`; label.classList.add(this._altCommand); } From 7d3bd2a15918259fce50c50a0b54f6ee6522e26b Mon Sep 17 00:00:00 2001 From: Danny Tuppeny Date: Tue, 25 Aug 2026 19:20:18 +0100 Subject: [PATCH 008/116] Note that InlayHints with the same position are shown in-order (#175525) See https://github.com/microsoft/language-server-protocol/issues/1680. --- src/vscode-dts/vscode.d.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/vscode-dts/vscode.d.ts b/src/vscode-dts/vscode.d.ts index 72ca5ce41bc..936c69a8267 100644 --- a/src/vscode-dts/vscode.d.ts +++ b/src/vscode-dts/vscode.d.ts @@ -5634,6 +5634,9 @@ declare module 'vscode' { /** * The position of this hint. + * + * If multiple hints have the same position, they will be shown in the order + * they appear in the results. */ position: Position; From 90f9b51baae791e6e5b20bd45785c2439ee28fb8 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Tue, 25 Aug 2026 11:49:41 -0700 Subject: [PATCH 009/116] Fix Electron types in PR checks (#332594) ci: prepare Electron types for PR checks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 86d22faf7e9..861b82fd214 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -61,6 +61,9 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Prepare Electron types + run: node build/npm/electronTypes.ts + - name: Type check /build/ scripts run: npm run typecheck working-directory: build From 63da5e8b18c9b17e80868cd8614b8965292a4a74 Mon Sep 17 00:00:00 2001 From: Remco Haszing Date: Tue, 25 Aug 2026 21:19:15 +0200 Subject: [PATCH 010/116] Fix out of bounds text selection with line wrapping (#262910) The original behaviour is confusing and somewhat hard to explain. Given a Monaco editor with text wrapping enabled, if the user starts selecting text and moves the mouse outside of the editor viewbox, to the right of the screen, the cursor in the editor would be somewhere halfway the editor. The editor cursor did move with the mouse. So moving the mouse further right, moved the cursor further forward. The text cursor could even move onto the next line, below the mouse position. In case of RTL, the behaviour was the same. Further right means more forward, meaning it was visually mirrored. The new behaviour with this PR is easier to explain. If the cursor moves out of bounds while selecting text, the selection will match the end of the wrapped line at column matching the cursor height. It also takes RTL into account. The new behaviour matches gedit, LibreOffice, Google Docs, HTML content in browsers, and even Monaco Editor with text wrapping disabled. Co-authored-by: Alexandru Dima --- .../browser/controller/dragScrolling.ts | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/vs/editor/browser/controller/dragScrolling.ts b/src/vs/editor/browser/controller/dragScrolling.ts index bba8a03f777..bb035d766ce 100644 --- a/src/vs/editor/browser/controller/dragScrolling.ts +++ b/src/vs/editor/browser/controller/dragScrolling.ts @@ -7,6 +7,7 @@ import * as dom from '../../../base/browser/dom.js'; import { Disposable, IDisposable } from '../../../base/common/lifecycle.js'; import { EditorOption } from '../../common/config/editorOptions.js'; import { Position } from '../../common/core/position.js'; +import { TextDirection } from '../../common/model.js'; import { ViewContext } from '../../common/viewModel/viewContext.js'; import { NavigationCommandRevealType } from '../coreCommands.js'; import { IMouseTarget, IMouseTargetOutsideEditor } from '../editorBrowser.js'; @@ -197,20 +198,18 @@ export class LeftRightDragScrollingOperation extends DragScrollingOperation { } const edgeLineNumber = this._position.position.lineNumber; - // First, try to find a position that matches the horizontal position of the mouse let mouseTarget: IMouseTarget; - { - const editorPos = createEditorPagePosition(this._viewHelper.viewDomNode); - const horizontalScrollbarHeight = this._context.configuration.options.get(EditorOption.layoutInfo).horizontalScrollbarHeight; - const pos = new PageCoordinates(this._mouseEvent.pos.x, editorPos.y + editorPos.height - horizontalScrollbarHeight - 0.1); - const relativePos = createCoordinatesRelativeToEditor(this._viewHelper.viewDomNode, editorPos, pos); - mouseTarget = this._mouseTargetFactory.createMouseTarget(this._viewHelper.getLastRenderData(), editorPos, pos, relativePos, null); - } - if (this._position.outsidePosition === 'left') { - mouseTarget = MouseTarget.createOutsideEditor(mouseTarget.mouseColumn, new Position(edgeLineNumber, mouseTarget.mouseColumn), 'left', this._position.outsideDistance); + // In case of RTL, the line is exceeded on the left. Otherwise on the right. + const isRtl = this._context.viewModel.getTextDirection(edgeLineNumber) === TextDirection.RTL; + const exceedingPosition = isRtl ? 'left' : 'right'; + if (this._position.outsidePosition === exceedingPosition) { + // Move the selection to the far end of the line. + const lineWidth = this._context.viewModel.getLineMaxColumn(edgeLineNumber); + mouseTarget = MouseTarget.createOutsideEditor(lineWidth, new Position(edgeLineNumber, lineWidth), 'right', this._position.outsideDistance); } else { - mouseTarget = MouseTarget.createOutsideEditor(mouseTarget.mouseColumn, new Position(edgeLineNumber, mouseTarget.mouseColumn), 'right', this._position.outsideDistance); + // Move the selection to the beginning of the line. + mouseTarget = MouseTarget.createOutsideEditor(1, new Position(edgeLineNumber, 1), 'left', this._position.outsideDistance); } this._dispatchMouse(mouseTarget, true, NavigationCommandRevealType.None); From 2726d6ad0cc4c176c74747306a8ae851e12930e6 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 25 Aug 2026 12:19:37 -0700 Subject: [PATCH 011/116] agent host: anchor side chat forks at the last completed turn (#332589) * agent host: anchor side chat forks at the last completed turn A side chat can branch from a turn that is still running. The host asked the provider to fork at that active turn, but an active turn is not yet in any provider history. Each provider then used a different fallback: Copilot inherited all turns, Claude created a fresh chat, and Codex made an error. Side chats were thus not available for Codex. The host now anchors the fork at the last completed non-local turn, and the side chat contribution adds only the turns that the fork cannot carry. Thus the side chat shows the source conversation one time. - Adds `resolveLastNonLocalTurnId` to the shared conversation context module. The host and the side chat contribution use the same helper, because the two values must agree. - Anchors an active-turn side chat fork at the last completed non-local turn. The origin keeps the identifier of the turn that the user branched from. - Removes the fork when no completed non-local turn is available, which makes a fresh chat. Turn execution uses per-chat queues, thus creation does not wait for the source turn. - Limits the added context for an active turn to the trailing local turns, the active turn message, and the partial response. - Enables side chats for Codex. No other Codex change is necessary. - Divides the advertised side chat capability from the end-to-end replay coverage with `supportsSideChatsE2E`, which obeys the `supportsChatForkE2E` pattern. The Codex scenario stays disabled until focused captures are available. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agent host: track the Codex side chat recording gap in known issues - Names the skipped test and the supportsSideChatsE2E gate in the Codex model-backed recording entry. - Adds a focused reproduction command for the side chat scenario. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/agentHostConversationContext.ts | 11 +++ .../platform/agentHost/node/agentService.ts | 29 +++++-- .../agentHost/node/chatContributions/TODO.md | 5 ++ .../sideChat/sideChatContext.ts | 5 +- .../sideChat/sideChatContribution.ts | 6 +- .../agentHost/node/codex/codexAgent.ts | 2 +- .../agentHost/test/node/agentService.test.ts | 77 ++++++++++++++++--- .../test/node/chatContributions.test.ts | 42 ++++++++++ .../sideChat/sideChatContext.test.ts | 31 +++++++- .../test/node/codex/codexCreateChat.test.ts | 4 +- .../agentHost/test/node/e2e/KNOWN_ISSUES.md | 16 ++-- .../e2e/harness/agentHostE2ETestHarness.ts | 2 + .../claudeAgentHostE2E.integrationTest.ts | 1 + .../e2e/providers/codexTestConfiguration.ts | 2 + .../e2e/providers/copilotTestConfiguration.ts | 1 + .../test/node/e2e/suites/multiChatSuite.ts | 2 +- 16 files changed, 204 insertions(+), 32 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostConversationContext.ts b/src/vs/platform/agentHost/common/agentHostConversationContext.ts index b774fe03746..31df2a2fb0f 100644 --- a/src/vs/platform/agentHost/common/agentHostConversationContext.ts +++ b/src/vs/platform/agentHost/common/agentHostConversationContext.ts @@ -96,3 +96,14 @@ export function truncateMiddle(text: string, maxChars: number): string { const tail = keep - head; return `${text.slice(0, head)}${marker}${text.slice(text.length - tail)}`; } + +/** Returns the last turn that is not host-injected local context. */ +export function resolveLastNonLocalTurnId(turns: readonly Turn[], isLocal: (turnId: string) => boolean): string | undefined { + for (let i = turns.length - 1; i >= 0; i--) { + const turn = turns[i]; + if (!isLocal(turn.id)) { + return turn.id; + } + } + return undefined; +} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index d9a9ce308f1..79c4e0416a3 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -75,6 +75,7 @@ import { INetworkDiagnosticsService } from './networkDiagnosticsService.js'; import { parseMcpChannelUri } from './shared/mcpCustomizationController.js'; import { toAgentClientUri } from '../common/agentClientUri.js'; import { AgentHostClientType } from '../common/agentHostClientInfo.js'; +import { resolveLastNonLocalTurnId } from '../common/agentHostConversationContext.js'; import { AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; import { AgentMergeController, type IAgentMergeControllerOptions } from './agentMergeController.js'; @@ -2751,11 +2752,18 @@ export class AgentService extends Disposable implements IAgentService { peerChatOrigin = resolvedSideChat.origin; createOptions = { ...providerOptions, - fork: { - source: URI.parse(resolvedSideChat.sourceChat), - turnId: resolvedSideChat.anchorTurnId ?? sideChat.turnId, - independentQueue: true, - }, + ...(resolvedSideChat.shouldFork + ? { + fork: { + source: URI.parse(resolvedSideChat.sourceChat), + turnId: resolvedSideChat.anchorTurnId ?? sideChat.turnId, + independentQueue: true, + }, + } + : { + // Active turns run on per-chat queues, so this fresh creation cannot wait behind the source turn. + fork: undefined, + }), }; } if (createOptions?.fork && !sideChat) { @@ -2860,7 +2868,7 @@ export class AgentService extends Disposable implements IAgentService { * origin. Throws when the source chat is not part of `session` or when the * referenced completed or active turn is absent. */ - private async _resolveSideChatOrigin(session: URI, sideChat: IAgentCreateChatSideChatSource): Promise<{ origin: ChatOrigin; sourceChat: string; selection?: IAgentCreateChatSideChatSelection; anchorTurnId?: string }> { + private async _resolveSideChatOrigin(session: URI, sideChat: IAgentCreateChatSideChatSource): Promise<{ origin: ChatOrigin; sourceChat: string; selection?: IAgentCreateChatSideChatSelection; anchorTurnId?: string; shouldFork: boolean }> { const sessionKey = session.toString(); const sourceKey = sideChat.source.toString(); const { sourceChatKey, sourceSessionKey, sourceState } = await this._resolveSessionSourceChat(sideChat.source); @@ -2876,8 +2884,12 @@ export class AgentService extends Disposable implements IAgentService { if (!hasCompletedTurn && !activeTurn) { throw new Error(`[AgentService] createChat: side chat source turn ${sideChat.turnId} not found in ${sourceKey}`); } - const isLocalSourceTurn = !activeTurn && this._localTurns.isLocal(sourceChatKey, sideChat.turnId); - const anchorTurnId = isLocalSourceTurn ? this._localTurns.resolveConcreteTurnId(sourceChatKey, sideChat.turnId) : undefined; + let anchorTurnId: string | undefined; + if (activeTurn) { + anchorTurnId = resolveLastNonLocalTurnId(sourceState?.turns ?? [], turnId => this._localTurns.isLocal(sourceChatKey, turnId)); + } else if (this._localTurns.isLocal(sourceChatKey, sideChat.turnId)) { + anchorTurnId = this._localTurns.resolveConcreteTurnId(sourceChatKey, sideChat.turnId); + } const selection = sideChat.selection?.text.trim() ? sideChat.selection : sideChat.selection @@ -2891,6 +2903,7 @@ export class AgentService extends Disposable implements IAgentService { ...(selection ? { selection } : {}), }, sourceChat: sourceChatKey, + shouldFork: !activeTurn || anchorTurnId !== undefined, ...(selection ? { selection } : {}), ...(anchorTurnId ? { anchorTurnId } : {}), }; diff --git a/src/vs/platform/agentHost/node/chatContributions/TODO.md b/src/vs/platform/agentHost/node/chatContributions/TODO.md index 2d110f09ed2..00ad9f1702b 100644 --- a/src/vs/platform/agentHost/node/chatContributions/TODO.md +++ b/src/vs/platform/agentHost/node/chatContributions/TODO.md @@ -59,6 +59,11 @@ Each contribution has its own subfolder so its implementation, helpers, and test - Add disposable-value semantics only when a contribution needs to store disposables. Memento eviction currently drops observables without disposing their values. - Consider a `chatDisposable` helper only when a real contribution needs it; do not add it speculatively. +## Side-chat follow-ups + +- Prefer `IAgentHostStateManager.getChatInheritedTurnId()` in `SideChatContribution.onOutgoingTurn`: it is the provider's ground-truth inherited boundary, handles dropped forks and Claude's fresh fallback, and avoids recomputing the requested anchor. Codex must first report `inheritedTurnId`; it currently computes `keepThroughIndex` without exposing it, and resolving its host-versus-thread turn IDs is the same id-space problem behind active-turn side chats. +- The source turn can complete between `createChat` and the first side-chat `onOutgoingTurn`. The fork was anchored before that turn, but the contribution then sees no active turn and injects no context, so the source turn is absent from both. This pre-existing race also occurs on main. + ## Payoff - Migrate btw/sideChat to one contribution (`onOutgoingTurn` plus `onHydrateTurns`), deleting the six per-harness wiring sites (`copilot/copilotAgent.ts:3651`, `:3755`, `:3900`; `claude/claudeAgent.ts:1414`, `:1987`, `:2359`) and the `sideChat` field from both `IPersistedChat` blobs. Codex gains btw support by deletion rather than addition. diff --git a/src/vs/platform/agentHost/node/chatContributions/sideChat/sideChatContext.ts b/src/vs/platform/agentHost/node/chatContributions/sideChat/sideChatContext.ts index cc74610ce5c..9d8962c00f2 100644 --- a/src/vs/platform/agentHost/node/chatContributions/sideChat/sideChatContext.ts +++ b/src/vs/platform/agentHost/node/chatContributions/sideChat/sideChatContext.ts @@ -45,9 +45,10 @@ export function getSideChatPartialResponse(activeTurn: ActiveTurn | undefined): return responseMarkdown ? truncateMiddle(responseMarkdown, MAX_SIDE_CHAT_CONTEXT_CHARS) : undefined; } -export function buildBoundedSideChatSourceContext(turns: readonly Turn[], turnId: string, activeTurn?: ActiveTurn): string | undefined { +export function buildBoundedSideChatSourceContext(turns: readonly Turn[], turnId: string, activeTurn?: ActiveTurn, forkAnchorTurnId?: string): string | undefined { if (activeTurn?.id === turnId) { - return buildSideChatSourceContext(turns, activeTurn); + const anchorIndex = forkAnchorTurnId === undefined ? -1 : turns.findIndex(turn => turn.id === forkAnchorTurnId); + return buildSideChatSourceContext(anchorIndex === -1 ? turns : turns.slice(anchorIndex + 1), activeTurn); } const turnIndex = turns.findIndex(turn => turn.id === turnId); return turnIndex === -1 ? undefined : buildSideChatSourceContext(turns.slice(0, turnIndex + 1)); diff --git a/src/vs/platform/agentHost/node/chatContributions/sideChat/sideChatContribution.ts b/src/vs/platform/agentHost/node/chatContributions/sideChat/sideChatContribution.ts index 8aae3502789..78e8e271743 100644 --- a/src/vs/platform/agentHost/node/chatContributions/sideChat/sideChatContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/sideChat/sideChatContribution.ts @@ -5,6 +5,7 @@ import { Disposable } from '../../../../../base/common/lifecycle.js'; import { createChatMementoKey, type IAgentHostChatContribution, type IAgentHostChatContributionContext, type IHydrationContext, type IOutgoingTurn, type ISendContribution, type ITurnEnd } from '../../../common/agentHostChatContributionsService.js'; +import { resolveLastNonLocalTurnId } from '../../../common/agentHostConversationContext.js'; import { ChatOriginKind } from '../../../common/state/protocol/state.js'; import { TurnState, type Turn } from '../../../common/state/sessionState.js'; import { IAgentHostStateManager, AgentHostStateManager } from '../../agentHostStateManager.js'; @@ -38,10 +39,13 @@ export class SideChatContribution extends Disposable implements IAgentHostChatCo const sourceState = this._stateManager.getChatState(origin.chat); const activeTurn = sourceState?.activeTurn?.id === origin.turnId ? sourceState.activeTurn : undefined; + const forkAnchorTurnId = activeTurn + ? resolveLastNonLocalTurnId(sourceState?.turns ?? [], turnId => this._localTurns.isLocal(origin.chat, turnId)) + : undefined; // A completed SDK-backed turn is already carried by the provider's fork. // Only active and host-injected local turns are missing from that history. const sourceContext = activeTurn || this._localTurns.isLocal(origin.chat, origin.turnId) - ? buildBoundedSideChatSourceContext(sourceState?.turns ?? [], origin.turnId, activeTurn) + ? buildBoundedSideChatSourceContext(sourceState?.turns ?? [], origin.turnId, activeTurn, forkAnchorTurnId) : undefined; const partialResponse = getSideChatPartialResponse(activeTurn); return { diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index d9ad1bafe54..1b1154e2a0d 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -3442,7 +3442,7 @@ export class CodexAgent extends Disposable implements IAgent { displayName: localize('codexAgent.displayName', "Codex"), description: localize('codexAgent.description', "Codex agent using session-selected model providers"), capabilities: { - multipleChats: { fork: true }, + multipleChats: { fork: true, sideChat: true }, ...(this._isMultiRootEnabled() ? { multipleWorkingDirectories: { immutablePrimary: true } } : {}), }, }; diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index d77a4bd386d..07aca652873 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -9317,7 +9317,7 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('creates a side chat from the current active turn', async () => { + test('creates a fresh side chat from the first active turn', async () => { const agent = disposables.add(new SideChatAgent('copilot')); service.registerProvider(agent); const session = await service.createSession({ provider: 'copilot' }); @@ -9350,7 +9350,7 @@ suite('AgentService (node dispatcher)', () => { }, { sourceActiveTurn: 'active-turn', origin: { kind: ChatOriginKind.SideChat, chat: sourceChat, turnId: 'active-turn' }, - forkForwarded: { source: sourceChat, turnId: 'active-turn', independentQueue: true }, + forkForwarded: undefined, }); }); @@ -9359,7 +9359,10 @@ suite('AgentService (node dispatcher)', () => { service.registerProvider(agent); const session = await service.createSession({ provider: 'copilot' }); const sourceChat = buildDefaultChatUri(session); - getStateManager(service).seedDefaultChatTurns(session.toString(), [completedTurn('t1', 'first question', 'first answer')]); + getStateManager(service).seedDefaultChatTurns(session.toString(), [ + completedTurn('t1', 'first question', 'first answer'), + completedTurn('t2', 'second question', 'second answer'), + ]); service.dispatchAction(sourceChat, { type: ActionType.ChatTurnStarted, turnId: 'active-turn', @@ -9375,14 +9378,70 @@ suite('AgentService (node dispatcher)', () => { await service.createChat(session, chatUri, { sideChat: { source: URI.parse(sourceChat), turnId: 'active-turn' } }); - assert.deepStrictEqual(agent.lastCreateOptions?.fork && { - source: agent.lastCreateOptions.fork.source.toString(), - turnId: agent.lastCreateOptions.fork.turnId, - independentQueue: agent.lastCreateOptions.fork.independentQueue, + assert.deepStrictEqual({ + origin: getStateManager(service).getChatState(chatUri.toString())?.origin, + forkForwarded: agent.lastCreateOptions?.fork && { + source: agent.lastCreateOptions.fork.source.toString(), + turnId: agent.lastCreateOptions.fork.turnId, + independentQueue: agent.lastCreateOptions.fork.independentQueue, + }, }, { - source: sourceChat, + origin: { kind: ChatOriginKind.SideChat, chat: sourceChat, turnId: 'active-turn' }, + forkForwarded: { + source: sourceChat, + turnId: 't2', + independentQueue: true, + }, + }); + }); + + test('skips trailing local turns while anchoring an active side chat', async () => { + const db = new TestSessionDatabase(); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new SideChatAgent('copilot')); + localService.registerProvider(agent); + const { session } = await createAgentSession(agent); + const sessionResource = (await agent.listSessions())[0].session; + const sourceChat = buildDefaultChatUri(sessionResource.toString()); + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'real-1', content: 'first question', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'real-1-a', content: 'first answer', toolRequests: [] }, + { type: 'message', session, role: 'user', messageId: 'real-2', content: 'second question', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'real-2-a', content: 'second answer', toolRequests: [] }, + ]; + const localTurn: Turn = { + id: 'local-turn', + state: TurnState.Complete, + message: { text: '!command', origin: { kind: MessageKind.User } }, + responseParts: [], + usage: undefined, + }; + await db.insertLocalTurn({ turnId: localTurn.id, chatUri: sourceChat, anchorTurnId: 'real-2', seq: 1, payload: JSON.stringify(localTurn) }); + await localService.restoreSession(sessionResource); + localService.dispatchAction(sourceChat, { + type: ActionType.ChatTurnStarted, turnId: 'active-turn', - independentQueue: true, + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'still running', origin: { kind: MessageKind.User } }, + }, 'test-client', 1); + const chatUri = URI.parse(buildChatUri(sessionResource, 'side-active-local')); + + await localService.createChat(sessionResource, chatUri, { sideChat: { source: URI.parse(sourceChat), turnId: 'active-turn' } }); + + assert.deepStrictEqual({ + origin: getStateManager(localService).getChatState(chatUri.toString())?.origin, + forkForwarded: agent.lastCreateOptions?.fork && { + source: agent.lastCreateOptions.fork.source.toString(), + turnId: agent.lastCreateOptions.fork.turnId, + independentQueue: agent.lastCreateOptions.fork.independentQueue, + }, + }, { + origin: { kind: ChatOriginKind.SideChat, chat: sourceChat, turnId: 'active-turn' }, + forkForwarded: { + source: sourceChat, + turnId: 'real-2', + independentQueue: true, + }, }); }); diff --git a/src/vs/platform/agentHost/test/node/chatContributions.test.ts b/src/vs/platform/agentHost/test/node/chatContributions.test.ts index 06dc13ca4fb..56ed742d68f 100644 --- a/src/vs/platform/agentHost/test/node/chatContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/chatContributions.test.ts @@ -1385,6 +1385,48 @@ suite('AgentHostChatContributions', () => { assert.strictEqual(first.message.text, injectSideChatContext('side question', undefined, 'User request:\nsource question')); }); + test('includes only local context after the active side-chat fork anchor', async () => { + const sideChat = createSideChatContributions(disposables); + sideChat.stateManager.dispatchServerAction(sideChat.sourceChat, { + type: ActionType.ChatTurnStarted, + turnId: 'source-concrete', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'source question', origin: { kind: MessageKind.User } }, + }); + sideChat.stateManager.dispatchServerAction(sideChat.sourceChat, { + type: ActionType.ChatTurnComplete, + turnId: 'source-concrete', + duration: 1, + }); + sideChat.stateManager.dispatchServerAction(sideChat.sourceChat, { + type: ActionType.ChatTurnStarted, + turnId: 'local-turn', + startedAt: '2025-01-01T00:00:01.000Z', + message: { text: '!command', origin: { kind: MessageKind.User } }, + }); + sideChat.stateManager.dispatchServerAction(sideChat.sourceChat, { + type: ActionType.ChatTurnComplete, + turnId: 'local-turn', + duration: 1, + }); + sideChat.localTurns.noteInMemory(sideChat.session, sideChat.sourceChat, 'local-turn', 'source-concrete', 1); + sideChat.stateManager.dispatchServerAction(sideChat.sourceChat, { + type: ActionType.ChatTurnStarted, + turnId: 'source-turn', + startedAt: '2025-01-01T00:00:02.000Z', + message: { text: 'still running', origin: { kind: MessageKind.User } }, + }); + + const first = await sideChat.service.outgoingTurn({ + session: sideChat.session, + chat: sideChat.sideChat, + message: { text: 'side question', origin: { kind: MessageKind.User } }, + turnId: 'side-turn', + }); + + assert.strictEqual(first.message.text, injectSideChatContext('side question', undefined, 'User request:\n!command\n\n---\n\nUser request:\nstill running')); + }); + test('injects context after failed or cancelled first side-chat attempts', async () => { const reasons: readonly ITurnEnd['reason'][] = [ { kind: 'error', error: { errorType: 'test', message: 'failed' } }, diff --git a/src/vs/platform/agentHost/test/node/chatContributions/sideChat/sideChatContext.test.ts b/src/vs/platform/agentHost/test/node/chatContributions/sideChat/sideChatContext.test.ts index 05574a231db..22f8e226516 100644 --- a/src/vs/platform/agentHost/test/node/chatContributions/sideChat/sideChatContext.test.ts +++ b/src/vs/platform/agentHost/test/node/chatContributions/sideChat/sideChatContext.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { resolveLastNonLocalTurnId } from '../../../../common/agentHostConversationContext.js'; import { MessageKind, ResponsePartKind, TurnState, type Turn } from '../../../../common/state/sessionState.js'; import { buildBoundedSideChatSourceContext, injectSideChatContext, resolveSideChatBoundary, sliceSideChatTurns } from '../../../../node/chatContributions/sideChat/sideChatContext.js'; @@ -72,7 +73,7 @@ suite('sideChatContext', () => { }), 'User request:\ncurrent question'); }); - test('captures completed context before an active turn', () => { + test('omits completed context carried by an active-turn fork', () => { assert.strictEqual(buildBoundedSideChatSourceContext([{ ...sourceTurn, responseParts: [{ kind: ResponsePartKind.Markdown, id: 'source-md', content: 'source answer' }], @@ -82,7 +83,33 @@ suite('sideChatContext', () => { responseParts: [], startedAt: new Date().toISOString(), usage: undefined, - }), 'User request:\nsource question\n\nAgent response:\nsource answer\n\n---\n\nUser request:\nfollow-up question'); + }, sourceTurn.id), 'User request:\nfollow-up question'); + }); + + test('includes completed turns after an active-turn fork anchor', () => { + const localTurn: Turn = { + ...sourceTurn, + id: 'local-turn', + message: { ...sourceTurn.message, text: '!command' }, + }; + + assert.strictEqual(buildBoundedSideChatSourceContext([sourceTurn, localTurn], 'active', { + id: 'active', + message: { text: 'follow-up question', origin: { kind: MessageKind.User } }, + responseParts: [], + startedAt: new Date().toISOString(), + usage: undefined, + }, sourceTurn.id), 'User request:\n!command\n\n---\n\nUser request:\nfollow-up question'); + }); + + test('resolves the final non-local turn', () => { + const turns: Turn[] = [ + sourceTurn, + { ...sourceTurn, id: 'second-turn' }, + { ...sourceTurn, id: 'local-turn' }, + ]; + + assert.strictEqual(resolveLastNonLocalTurnId(turns, turnId => turnId === 'local-turn'), 'second-turn'); }); test('injects active source context and partial responses exactly once', () => { diff --git a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts index df5cdb3d21f..cb8064368ae 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts @@ -325,10 +325,10 @@ suite('CodexAgent createChat', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('advertises chat fork support without side-chat support', async () => { + test('advertises chat fork and side-chat support', async () => { const agent = await createAgent(disposables); - assert.deepStrictEqual(agent.getDescriptor().capabilities?.multipleChats, { fork: true }); + assert.deepStrictEqual(agent.getDescriptor().capabilities?.multipleChats, { fork: true, sideChat: true }); }); test('fresh: binds the exact target chat during creation, never leaving the runtime unbound', async () => { diff --git a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md index 1545e6e3188..0adefce3722 100644 --- a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md +++ b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md @@ -863,11 +863,11 @@ Use the affected provider command with `--grep ""` and tempora ### Codex model-backed multiple-chat recording -- Tests: the model-backed peer-chat and fork scenarios in `multiChatSuite.ts`. -- Scope: Codex recording and strict replay only. Codex advertises `multipleChats.fork`; host-only capability checks and conformance catalog/lifecycle scenarios run. -- Expected: focused `AGENT_HOST_UPDATE_SNAPSHOTS=1` recording produces Codex peer/fork captures that replay without cache misses. -- Observed: on the current live recording path, even the existing simple Codex recording fails before producing a usable model response; peer turns report a CAPI malformed authorization-header error. No fixtures are accepted or hand-edited. -- Gate: `supportsMultipleChatsE2E: false` and `supportsChatForkE2E: false`. +- Tests: the model-backed peer-chat and fork scenarios in `multiChatSuite.ts`, and `side chat receives bounded source context without copied history`. +- Scope: Codex recording and strict replay only. Codex advertises `multipleChats.fork` and `multipleChats.sideChat`; host-only capability checks and conformance catalog/lifecycle scenarios run. +- Expected: focused `AGENT_HOST_UPDATE_SNAPSHOTS=1` recording produces Codex peer/fork/side-chat captures that replay without cache misses. +- Observed: on the current live recording path, even the existing simple Codex recording fails before producing a usable model response; peer turns report a CAPI malformed authorization-header error. The side-chat scenario shares that recording path and has no accepted capture. No fixtures are accepted or hand-edited. +- Gate: `supportsMultipleChatsE2E: false`, `supportsChatForkE2E: false`, and `supportsSideChatsE2E: false`. - Reproduce: ```bash @@ -875,6 +875,10 @@ Use the affected provider command with `--grep ""` and tempora AGENT_HOST_UPDATE_SNAPSHOTS=1 ./scripts/test-integration.sh --run \ src/vs/platform/agentHost/test/node/e2e/providers/codexAgentHostE2E.integrationTest.ts \ --grep "peer chat completes a simple turn" + + AGENT_HOST_UPDATE_SNAPSHOTS=1 ./scripts/test-integration.sh --run \ + src/vs/platform/agentHost/test/node/e2e/providers/codexAgentHostE2E.integrationTest.ts \ + --grep "side chat receives bounded source context without copied history" ``` ## Test-design limitations @@ -899,7 +903,7 @@ A test that checks only its final dispatch can miss an earlier action that was e |---|---|---|---| | Model-backed multiple chats | `supportsMultipleChatsE2E` | Codex | Capability and conformance scenarios run; provider/model peer turns skip until focused Codex captures can be recorded. | | Provider-backed fork parity | `supportsChatForkE2E` | Claude, Codex | Fork capability remains advertised; model-backed fork-context assertions skip. | -| Side chats | `supportsSideChats` | Codex | Provider-owned hidden-context and restore scenarios skip; ordinary peer chats and chat forks still run. | +| Side-chat context parity | `supportsSideChatsE2E` | Codex | Side-chat capability remains advertised; model-backed hidden-context assertions skip pending focused Codex captures. | | Subagents | `supportsSubagents` | Codex | Subagent routing and reopen scenarios skip. | | Streaming file creation | `streamingFileCreateToolName` | Codex | Argument-delta coverage requires a native file-creation tool; shell-backed file behavior is covered separately. | | Plan mode | `supportsPlanMode` | Codex | The plan-mode scenario skips. Claude's use of the same gate is the prompt limitation above. | diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts index 09072b227dc..91c80ad52af 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts @@ -344,6 +344,8 @@ export interface IAgentHostE2EProviderConfig { readonly supportsSubagents: boolean; /** Whether the provider supports creating side chats from a source turn. */ readonly supportsSideChats?: boolean; + /** Whether committed replay fixtures cover side-chat behavior for this provider. */ + readonly supportsSideChatsE2E?: boolean; /** * When set, shell-dependent replay tests are skipped on Linux because this * provider completes recorded shell-tool turns without emitting tool-call diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts b/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts index 3b23c0ec95a..6ad627c1a4e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts @@ -83,6 +83,7 @@ const CLAUDE_CONFIG: IAgentHostE2EProviderConfig = { supportsHostTerminalTool: false, supportsSubagents: true, supportsSideChats: true, + supportsSideChatsE2E: true, // Claude rebuilds a reopened subagent transcript from the SDK's on-disk // `subagents/agent-*.jsonl`, not reliably visible on Windows (see PR #325284). subagentReplayUnstableOnWindows: true, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts b/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts index 3181a160d7f..9741870d938 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts @@ -33,6 +33,8 @@ export const CODEX_CONFIG: IAgentHostE2EProviderConfig = { supportsMultipleChatsE2E: false, supportsChatFork: true, supportsChatForkE2E: false, + supportsSideChats: true, + supportsSideChatsE2E: false, shellToolReplayUnstableOnLinux: true, shellToolResultTextUnreliable: true, }; diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/copilotTestConfiguration.ts b/src/vs/platform/agentHost/test/node/e2e/providers/copilotTestConfiguration.ts index d1cbda4748e..97becb10b0a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/copilotTestConfiguration.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/copilotTestConfiguration.ts @@ -34,6 +34,7 @@ export const COPILOT_CONFIG: IAgentHostE2EProviderConfig = { supportsHostTerminalTool: true, supportsSubagents: true, supportsSideChats: true, + supportsSideChatsE2E: true, supportsPlanMode: true, supportsMultipleChats: true, supportsChatFork: true, diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts index 8a07b9d83dd..235993f170c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts @@ -864,7 +864,7 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void { firstMessage: question, firstAttachments: [], }); - }, config.supportsMultipleChats && !!config.supportsSideChats); + }, config.supportsMultipleChats && config.supportsSideChatsE2E === true); providerTest('two peer chats keep independent provider contexts', async function () { const { sessionUri } = await createSession('two-contexts'); From 6a60e32fab82e03830238273f20cef26128602d8 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:22:10 +0200 Subject: [PATCH 012/116] Back off GitHub requests instead of hammering a failing service (#332588) * Back off GitHub requests instead of hammering a failing service GitHubCredentialService kept no memory of failure: any failed identity resolution cleared the current generation, so the next getCredential() immediately reissued GET /user. A 401 amplified that, because handleRequestError invalidates the generation and both PullRequestResourceService and GitHubQueryService answer the resulting onDidInvalidate by rescheduling every fragment and entity at now, each of which asks for a credential again. When /user succeeded but real requests kept being refused, that was an unbounded zero-delay loop for every user with a subscribed pull request. Add githubBackoff.ts with the shared escalating-delay math and a GitHubBackoffGate that holds back attempts against a subject that keeps failing. Callers wait rather than being rejected, so recovery stays automatic and everyone queued behind one delay shares the attempt that follows it. A working /user deliberately does not clear the record, since it only proves identity resolution recovered; recovery is signalled by a new token, a new host, or the record decaying while nothing fails. Also unify the three other escalating retries onto the same helper, give the capability probe a negative cache so an unusable result is not re-probed on every fragment fetch, and stop trusting rate limit hints that have already elapsed or that report the primary quota window for a secondary limit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Park primary 403 rate limits and unpin capabilities on re-auth Address review feedback. GitHub reports primary rate limit exhaustion as a 403 carrying spent quota headers rather than as a 429, so that form skipped the refusal floor and fell through to getDelay's remaining/resetAt fallback. When the reset was missing or had already elapsed, the caller retried at once. Treat every rate-limited refusal alike, keeping an authorization 403 unparked so a credential problem still surfaces immediately. The degraded capability record was keyed only by host and enterprise version, yet a probe can be refused for the credential rather than the host, for instance under SAML enforcement. Re-authenticating therefore stayed pinned to the incomplete REST fallbacks for up to the fifteen minute maximum. Scope the record to the credential generation so a new credential probes at once, while successful host entries stay host-scoped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../platform/github/common/githubBackoff.ts | 145 +++++++++++++++++ .../github/common/githubCredentialService.ts | 72 ++++++++- .../common/githubHostCapabilitiesService.ts | 64 +++++++- .../github/common/githubQueryServiceImpl.ts | 19 ++- .../common/githubRateLimitCoordinator.ts | 68 +++++--- .../platform/github/common/githubService.ts | 4 +- .../common/pullRequestResourceService.ts | 10 +- .../test/node/githubCredentialService.test.ts | 150 +++++++++++++++++- .../githubHostCapabilitiesService.test.ts | 86 ++++++++-- .../test/node/githubQueryService.test.ts | 70 ++++++++ .../github/test/node/githubTransport.test.ts | 116 ++++++++++++++ .../node/pullRequestResourceService.test.ts | 3 +- 12 files changed, 753 insertions(+), 54 deletions(-) create mode 100644 src/vs/platform/github/common/githubBackoff.ts diff --git a/src/vs/platform/github/common/githubBackoff.ts b/src/vs/platform/github/common/githubBackoff.ts new file mode 100644 index 00000000000..b2cafcdd76b --- /dev/null +++ b/src/vs/platform/github/common/githubBackoff.ts @@ -0,0 +1,145 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../base/common/lifecycle.js'; +import { ILogService } from '../../log/common/log.js'; +import { IGitHubScheduler, schedulerDelay } from './githubScheduler.js'; + +/** + * Shapes how far apart repeated attempts against a failing subject are spaced. + * Without one, every subscriber that reacts to a failure retries at its normal + * rate for the whole outage, which turns one unhealthy dependency into a + * request storm against GitHub from every user at once. + */ +export interface GitHubBackoffPolicy { + /** Consecutive failures that may retry without waiting, so a single blip still recovers at once. */ + readonly immediateRetries: number; + readonly base: number; + readonly maximum: number; + readonly jitter: number; + /** + * Quiet time after which consecutive failures are forgotten. Only + * {@link GitHubBackoffGate} consults it, for subjects whose recovery nothing + * else can report; callers that observe a success reset the count directly. + */ + readonly decay?: number; +} + +/** + * The delay an attempt must serve after `attempts` consecutive failures, never + * shorter than `minimum`. Jittered so a host that fails many callers at once + * does not gather them into a single retry burst when the delay elapses. + */ +export function gitHubBackoffDelay(policy: GitHubBackoffPolicy, scheduler: IGitHubScheduler, attempts: number, minimum = 0): number { + const escalated = attempts <= policy.immediateRetries + ? 0 + : Math.min(policy.base * 2 ** (attempts - policy.immediateRetries - 1), policy.maximum); + const delay = Math.max(escalated, minimum); + // An attempt that is free to run now must stay immediate rather than being + // pushed onto the jitter window. + return delay === 0 ? 0 : delay + scheduler.jitter(policy.jitter); +} + +interface IBackoffState { + readonly key: string; + readonly attempts: number; + readonly recordedAt: number; + readonly blockedUntil: number; +} + +/** + * Holds back attempts against a single subject that keeps failing, spacing them + * further apart the longer the trouble lasts. + * + * Callers wait rather than being rejected, so recovery stays automatic and + * everyone queued behind one delay shares the single attempt that follows it. + * The subject is named by an opaque key -- which may carry a secret and is + * therefore never logged -- so replacing it recovers immediately. + */ +export class GitHubBackoffGate extends Disposable { + + private readonly _lifetime = new AbortController(); + private _changed = new AbortController(); + private _state: IBackoffState | undefined; + + constructor( + private readonly _label: string, + private readonly _policy: GitHubBackoffPolicy, + private readonly _scheduler: IGitHubScheduler, + private readonly _logService?: ILogService, + ) { + super(); + } + + /** + * Waits until an attempt for `key` may run and reports whether it had to. + * A key this gate holds no failure for proceeds at once. + */ + async wait(key: string, signal: AbortSignal): Promise { + let waited = false; + while (this._state) { + const state = this._state; + // A different subject has never failed, so it is tried at once + // instead of serving out the previous one's delay. + if (state.key !== key) { + this._set(undefined); + return waited; + } + const remaining = state.blockedUntil - this._scheduler.now(); + if (remaining <= 0) { + return waited; + } + this._logService?.debug(`[GitHubBackoffGate] Delaying ${this._label} by ${remaining}ms after ${state.attempts} consecutive failure(s)`); + const changed = this._changed.signal; + waited = true; + try { + await schedulerDelay(this._scheduler, remaining, AbortSignal.any([signal, this._lifetime.signal, changed])); + } catch (error) { + if (!changed.aborted || signal.aborted || this._lifetime.signal.aborted) { + throw error; + } + // The record changed, so the new one decides how much longer to wait. + } + } + return waited; + } + + /** Records a failure for `key`, so the next attempt for it waits longer. */ + fail(key: string): void { + const now = this._scheduler.now(); + const state = this._state; + // A subject that has gone quiet for a whole decay window is treated as + // healthy again, so an isolated failure much later still retries at once. + const continues = state !== undefined + && state.key === key + && now - state.recordedAt <= (this._policy.decay ?? Number.POSITIVE_INFINITY); + const attempts = (continues ? state.attempts : 0) + 1; + const delay = gitHubBackoffDelay(this._policy, this._scheduler, attempts); + this._set({ key, attempts, recordedAt: now, blockedUntil: now + delay }); + if (delay > 0) { + this._logService?.warn(`[GitHubBackoffGate] Backing off ${this._label} by ${delay}ms after ${attempts} consecutive failure(s)`); + } + } + + /** Forgets the recorded failures, releasing anyone already waiting. */ + reset(): void { + if (this._state) { + this._set(undefined); + } + } + + override dispose(): void { + this._state = undefined; + this._lifetime.abort(new Error(`GitHub ${this._label} backoff was disposed`)); + super.dispose(); + } + + /** Replaces the record and wakes every caller waiting on the previous one. */ + private _set(state: IBackoffState | undefined): void { + this._state = state; + this._changed.abort(); + this._changed = new AbortController(); + } +} diff --git a/src/vs/platform/github/common/githubCredentialService.ts b/src/vs/platform/github/common/githubCredentialService.ts index 50ea8ce7227..8bf3599a6bb 100644 --- a/src/vs/platform/github/common/githubCredentialService.ts +++ b/src/vs/platform/github/common/githubCredentialService.ts @@ -7,6 +7,8 @@ import { Event, Emitter } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { ILogService } from '../../log/common/log.js'; import { GitHubAccountHandle, IGitHubEndpointProvider, IGitHubTokenProvider } from './githubTypes.js'; +import { GitHubBackoffGate, GitHubBackoffPolicy } from './githubBackoff.js'; +import { IGitHubScheduler, systemGitHubScheduler } from './githubScheduler.js'; import { GitHubRequestError, IGitHubTransport } from './githubTransport.js'; export interface GitHubCredential { @@ -28,6 +30,20 @@ export interface IGitHubCredentials { handleRequestError(credential: GitHubCredential, error: unknown): void; } +/** + * How long identity resolution waits before retrying a credential GitHub has + * already refused or failed to answer for. Without it every subscriber that + * asks for a credential turns an authentication outage into a request storm, + * because each refusal invalidates the generation the next request rebuilds. + */ +const defaultBackoffPolicy: GitHubBackoffPolicy = { + immediateRetries: 1, + base: 5_000, + maximum: 120_000, + decay: 300_000, + jitter: 2_000, +}; + interface ICredentialGeneration { readonly token: string; readonly generation: number; @@ -45,17 +61,21 @@ export class GitHubCredentialService extends Disposable implements IGitHubCreden private readonly _onDidInvalidate = this._register(new Emitter()); readonly onDidInvalidate = this._onDidInvalidate.event; + private readonly _backoff: GitHubBackoffGate; private _current: ICredentialGeneration | undefined; private _lastCredential: GitHubCredential | undefined; private _generation = 0; constructor( + scheduler: IGitHubScheduler | undefined, + policy: GitHubBackoffPolicy = defaultBackoffPolicy, private readonly _transport: IGitHubTransport, private readonly _tokenProvider: IGitHubTokenProvider, private readonly _endpointProvider: IGitHubEndpointProvider, private readonly _logService?: ILogService, ) { super(); + this._backoff = this._register(new GitHubBackoffGate('GitHub identity resolution', policy, scheduler ?? systemGitHubScheduler, _logService)); if (this._tokenProvider.onDidChangeToken) { this._register(this._tokenProvider.onDidChangeToken(() => this._invalidateCurrent('replacement'))); } @@ -100,9 +120,21 @@ export class GitHubCredentialService extends Disposable implements IGitHubCreden super.dispose(); } - private _resolve(token: string, signal: AbortSignal): Promise { + private async _resolve(token: string, signal: AbortSignal): Promise { if (signal.aborted) { - return Promise.reject(signal.reason); + throw signal.reason; + } + if (await this._backoff.wait(this._backoffKey(token, this._currentHost()), signal)) { + // The wait is long enough for the credential to have been replaced, + // and resolving the superseded one would abort the request the + // replacement is already making. + if (await this._tokenProvider.getToken(signal) !== token) { + this._logService?.debug('[GitHubCredentialService] Abandoning a credential that was replaced while backing off'); + throw new GitHubRequestError('GitHub authentication is required', 'authentication'); + } + } + if (signal.aborted) { + throw signal.reason; } if (!this._current || this._current.token !== token) { const previousCredential = this._lastCredential; @@ -120,18 +152,29 @@ export class GitHubCredentialService extends Disposable implements IGitHubCreden promise: this._resolveIdentity(token, generation, host, apiBaseUri, controller.signal) .then(credential => { current.credential = credential; + // Deliberately does not clear the failure record: a working + // `/user` only proves identity resolution recovered, and when + // GitHub is refusing this credential for real requests every + // round would otherwise reset the delay to zero and hammer + // the outage. Recovery is instead signalled by a new token, + // a new host, or the record decaying while nothing fails. + this._logService?.debug(`[GitHubCredentialService] Resolved account identity for ${host} (generation ${generation})`); if (previousCredential && !sameAccount(previousCredential.account, credential.account)) { this._logService?.debug(`[GitHubCredentialService] Account changed on ${host} at generation ${generation}`); this._onDidInvalidate.fire({ credential: previousCredential, reason: 'account' }); } this._lastCredential = credential; - this._logService?.debug(`[GitHubCredentialService] Resolved account identity for ${host} (generation ${generation})`); return credential; }) .catch(error => { if (this._current === current) { this._current = undefined; } + // An invalidated generation was not refused by GitHub, so + // it must not count towards the delay the next one serves. + if (!controller.signal.aborted) { + this._backoff.fail(this._backoffKey(token, host)); + } this._logService?.debug(`[GitHubCredentialService] Account identity resolution failed for ${host} (generation ${generation}, ${credentialErrorKind(error)})`); throw error; }), @@ -141,6 +184,18 @@ export class GitHubCredentialService extends Disposable implements IGitHubCreden return waitForCredential(this._current.promise, signal); } + /** + * Names the credential the gate holds back. Two different tokens, or the + * same token against two hosts, have not each been refused. + */ + private _backoffKey(token: string, host: string): string { + return `${host}\x00${token}`; + } + + private _currentHost(): string { + return new URL(this._endpointProvider.getApiBaseUri()).host.toLowerCase(); + } + private async _resolveIdentity(token: string, generation: number, host: string, apiBaseUri: string, signal: AbortSignal): Promise { const bootstrapAccount: GitHubAccountHandle = { host, accountId: `bootstrap:${generation}` }; let response; @@ -174,6 +229,11 @@ export class GitHubCredentialService extends Disposable implements IGitHubCreden } private _invalidateCurrent(reason: GitHubCredentialInvalidation['reason']): void { + // The gate keys its record by host, so a credential held back on the + // previous endpoint must not keep the new one waiting. + if (reason === 'endpoint') { + this._backoff.reset(); + } const current = this._current; if (!current) { if (reason === 'replacement' && this._lastCredential) { @@ -187,6 +247,12 @@ export class GitHubCredentialService extends Disposable implements IGitHubCreden } this._logService?.debug(`[GitHubCredentialService] Invalidating generation ${current.generation} on ${current.host} (${reason})`); this._current = undefined; + // A refused credential is counted before subscribers are told, because + // they answer the invalidation by asking for a credential again right + // away and would otherwise reissue the request GitHub just refused. + if (reason === 'authentication') { + this._backoff.fail(this._backoffKey(current.token, current.host)); + } current.controller.abort(new GitHubRequestError('GitHub credential generation was invalidated', 'authentication')); if (current.credential) { this._transport.invalidateAccount(current.credential.account); diff --git a/src/vs/platform/github/common/githubHostCapabilitiesService.ts b/src/vs/platform/github/common/githubHostCapabilitiesService.ts index ed2256336b3..11b6064799a 100644 --- a/src/vs/platform/github/common/githubHostCapabilitiesService.ts +++ b/src/vs/platform/github/common/githubHostCapabilitiesService.ts @@ -7,6 +7,8 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { ILogService } from '../../log/common/log.js'; import { GitHubHostCapabilities, IGitHubEndpointProvider } from './githubTypes.js'; import { GitHubCredential } from './githubCredentialService.js'; +import { GitHubBackoffPolicy, gitHubBackoffDelay } from './githubBackoff.js'; +import { IGitHubScheduler, systemGitHubScheduler } from './githubScheduler.js'; import { GitHubGraphQLError, IGitHubTransport } from './githubTransport.js'; const unavailableCapabilities: GitHubHostCapabilities = { @@ -52,6 +54,33 @@ interface ICachedCapabilities { settled: boolean; } +/** + * How long a degraded probe result is reused before the host is asked again. + * A result that cannot be cached is otherwise re-probed on every capability + * lookup, and because the fragments that ask still succeed on REST fallbacks + * nothing throttles it: a host that always answers with an unexpected error + * would pay one extra introspection query per poll forever. + */ +const defaultProbeBackoff: GitHubBackoffPolicy = { + immediateRetries: 0, + base: 60_000, + maximum: 900_000, + jitter: 5_000, +}; + +interface IDegradedCapabilities { + readonly capabilities: GitHubHostCapabilities; + readonly attempts: number; + readonly retryAt: number; + /** + * The credential the degraded result was observed with. A probe can be + * refused for the credential rather than the host (SAML enforcement, a + * revoked grant), so re-authenticating must not stay pinned to the + * fallbacks that refusal produced. + */ + readonly generation: number; +} + export interface IGitHubCapabilities { getCapabilities(credential: GitHubCredential, enterpriseVersion: string | undefined, signal: AbortSignal): Promise; clear(): void; @@ -60,13 +89,18 @@ export interface IGitHubCapabilities { export class GitHubHostCapabilitiesService extends Disposable implements IGitHubCapabilities { private readonly _cache = new Map(); + private readonly _degraded = new Map(); + private readonly _scheduler: IGitHubScheduler; constructor( + scheduler: IGitHubScheduler | undefined, + private readonly _policy: GitHubBackoffPolicy = defaultProbeBackoff, private readonly _transport: IGitHubTransport, private readonly _endpointService: IGitHubEndpointProvider, private readonly _logService?: ILogService, ) { super(); + this._scheduler = scheduler ?? systemGitHubScheduler; this._register(this._endpointService.onDidChange(() => this.clear())); } @@ -75,6 +109,16 @@ export class GitHubHostCapabilitiesService extends Disposable implements IGitHub return Promise.reject(signal.reason); } const key = `${credential.account.host.toLowerCase()}\x00${enterpriseVersion ?? ''}`; + const degraded = this._degraded.get(key); + if (degraded && degraded.generation !== credential.generation) { + // A new credential has never been refused, so it is probed at once + // rather than inheriting the previous one's fallbacks. + this._degraded.delete(key); + this._logService?.debug(`[GitHubHostCapabilitiesService] Discarding degraded capabilities for ${credential.account.host} because the credential changed`); + } else if (degraded && this._scheduler.now() < degraded.retryAt) { + this._logService?.trace(`[GitHubHostCapabilitiesService] Reusing degraded capabilities for ${credential.account.host} for another ${degraded.retryAt - this._scheduler.now()}ms`); + return Promise.resolve(degraded.capabilities); + } let cached = this._cache.get(key); if (!cached) { this._logService?.debug(`[GitHubHostCapabilitiesService] Probing capabilities for ${credential.account.host}${enterpriseVersion ? ` (${enterpriseVersion})` : ''}`); @@ -83,8 +127,13 @@ export class GitHubHostCapabilitiesService extends Disposable implements IGitHub controller, promise: this._probe(credential, controller.signal) .then(result => { - if (!result.cache && this._cache.get(key) === entry) { - this._cache.delete(key); + if (result.cache) { + this._degraded.delete(key); + } else { + this._recordDegraded(key, credential, result.capabilities); + if (this._cache.get(key) === entry) { + this._cache.delete(key); + } } this._logService?.debug(`[GitHubHostCapabilitiesService] Capabilities for ${credential.account.host}: ${formatCapabilities(result.capabilities)} (cached: ${result.cache})`); return result.capabilities; @@ -123,6 +172,7 @@ export class GitHubHostCapabilitiesService extends Disposable implements IGitHub entry.controller.abort(new Error('GitHub capability cache was cleared')); } this._cache.clear(); + this._degraded.clear(); } override dispose(): void { @@ -130,6 +180,16 @@ export class GitHubHostCapabilitiesService extends Disposable implements IGitHub super.dispose(); } + private _recordDegraded(key: string, credential: GitHubCredential, capabilities: GitHubHostCapabilities): void { + const previous = this._degraded.get(key); + // Only failures the same credential kept hitting escalate; a fresh one + // starts over so it is retried promptly. + const attempts = (previous?.generation === credential.generation ? previous.attempts : 0) + 1; + const delay = gitHubBackoffDelay(this._policy, this._scheduler, attempts); + this._degraded.set(key, { capabilities, attempts, retryAt: this._scheduler.now() + delay, generation: credential.generation }); + this._logService?.debug(`[GitHubHostCapabilitiesService] Reusing degraded capabilities for ${credential.account.host} for ${delay}ms after ${attempts} unusable probe(s)`); + } + private async _probe(credential: GitHubCredential, signal: AbortSignal): Promise { const response = await this._transport.graphql( credential.account, diff --git a/src/vs/platform/github/common/githubQueryServiceImpl.ts b/src/vs/platform/github/common/githubQueryServiceImpl.ts index 690b4e2402e..c74dd9971dc 100644 --- a/src/vs/platform/github/common/githubQueryServiceImpl.ts +++ b/src/vs/platform/github/common/githubQueryServiceImpl.ts @@ -38,6 +38,7 @@ import { GitHubCredential, GitHubCredentialInvalidation, IGitHubCredentials } fr import { IGitHubCapabilities } from './githubHostCapabilitiesService.js'; import { IGitHubScheduler, systemGitHubScheduler } from './githubScheduler.js'; import { GitHubGraphQLError, GitHubRequestError, IGitHubTransport } from './githubTransport.js'; +import { GitHubBackoffPolicy, gitHubBackoffDelay } from './githubBackoff.js'; import { IGitHubEndpointProvider } from './githubTypes.js'; import { PullRequestScheduler } from './pullRequestScheduler.js'; @@ -50,6 +51,7 @@ export interface GitHubEntityPollingPolicy { readonly maximumDormantEntries: number; readonly visible: number; readonly background: number; + readonly failureBackoff: GitHubBackoffPolicy; readonly jitter: number; } @@ -58,6 +60,7 @@ const defaultPollingPolicy: GitHubEntityPollingPolicy = { maximumDormantEntries: 50, visible: 60_000, background: 300_000, + failureBackoff: { immediateRetries: 0, base: 60_000, maximum: 900_000, jitter: 5_000 }, jitter: 5_000, }; @@ -136,6 +139,8 @@ class EntityEntry { readonly keys = new Set(); operation: IEntityOperation | undefined; dormantAt: number | undefined; + /** Consecutive refresh failures, so repeated trouble is retried further apart. */ + failureCount = 0; disposed = false; constructor( @@ -637,6 +642,7 @@ export class GitHubQueryService extends Disposable implements IGitHubQuery { this._canonicalizeRepository(entry as EntityEntry, value as GitHubRepository); } this._logService.trace(`[GitHubQueryService] Refreshed ${entry.kind} ${formatEntityRef(entry.ref)} in ${this._clock.now() - startedAt}ms (entry ${entry.id})`); + entry.failureCount = 0; if (this._shouldPollEntity(entry)) { this._scheduleEntity(entry, this._clock.now() + this._pollDelay(entry) + this._clock.jitter(this._policy.jitter)); } @@ -657,7 +663,7 @@ export class GitHubQueryService extends Disposable implements IGitHubQuery { error: toFragmentError(error), }, undefined); if (!(error instanceof GitHubRequestError) || error.kind !== 'authentication') { - this._scheduleEntity(entry, this._clock.now() + this._pollDelay(entry) + this._clock.jitter(this._policy.jitter)); + this._scheduleAfterFailure(entry); } } this._logService.debug(`[GitHubQueryService] Refresh ${entry.kind} ${formatEntityRef(entry.ref)} ${controller.signal.aborted ? 'cancelled' : 'failed'} after ${this._clock.now() - startedAt}ms (${queryErrorKind(error)})`); @@ -865,6 +871,17 @@ export class GitHubQueryService extends Disposable implements IGitHubQuery { return this._effectivePriority(entry) === 'background' ? this._policy.background : this._policy.visible; } + /** + * Retries a failed refresh no sooner than its poll cadence and further apart + * the longer the trouble lasts, so a GitHub outage is not met with the same + * request rate from every subscriber for its whole duration. + */ + private _scheduleAfterFailure(entry: EntityEntry): void { + entry.failureCount++; + const delay = gitHubBackoffDelay(this._policy.failureBackoff, this._clock, entry.failureCount, this._pollDelay(entry)); + this._scheduleEntity(entry, this._clock.now() + delay); + } + private _shouldPollEntity(entry: EntityEntry): boolean { if (entry.kind === 'repository') { return true; diff --git a/src/vs/platform/github/common/githubRateLimitCoordinator.ts b/src/vs/platform/github/common/githubRateLimitCoordinator.ts index 9ad03a7b877..00b3fc3dcbc 100644 --- a/src/vs/platform/github/common/githubRateLimitCoordinator.ts +++ b/src/vs/platform/github/common/githubRateLimitCoordinator.ts @@ -16,6 +16,9 @@ export interface GitHubRateLimitState { readonly blockedUntil?: number; } +/** GitHub's documented floor for retrying a rate limit it gave no reset hint for. */ +const unhintedRateLimitCooldown = 60_000; + export class GitHubRateLimitCoordinator extends Disposable { private readonly _states = new Map(); @@ -53,26 +56,41 @@ export class GitHubRateLimitCoordinator extends Disposable { const resource = response.headers.get('x-ratelimit-resource') ?? 'core'; const key = this._key(account, resource); const previous = this._states.get(key); - const retryAfter = parseSeconds(response.headers.get('retry-after'), this._scheduler.now()); + const now = this._scheduler.now(); + const retryAfter = parseSeconds(response.headers.get('retry-after'), now); const resetSeconds = parseNumber(response.headers.get('x-ratelimit-reset')); - const secondaryLimited = isSecondaryRateLimit(response.status, responseBody); - const blockedUntil = !secondaryLimited && retryAfter !== undefined - ? this._scheduler.now() + retryAfter * 1000 - : !secondaryLimited && response.status === 429 - ? resetSeconds !== undefined ? resetSeconds * 1000 : previous?.blockedUntil - : undefined; + const remaining = parseNumber(response.headers.get('x-ratelimit-remaining')); + const rateLimited = isRateLimited(response.status, responseBody); + const secondaryLimited = rateLimited && isSecondaryRateLimit(responseBody); + // GitHub's documented order: honour `retry-after`; otherwise wait for the + // reset only once the quota is actually spent. A secondary limit reports + // the primary window, so obeying its reset would park the account for up + // to an hour over a refusal that needs a minute. + const hinted = retryAfter !== undefined + ? now + retryAfter * 1000 + : remaining === 0 && resetSeconds !== undefined ? resetSeconds * 1000 : undefined; + // A refusal must always park the caller, including when the only hint + // GitHub gave has already elapsed and would otherwise retry at once. + const refusedUntil = hinted !== undefined && hinted > now ? hinted : now + unhintedRateLimitCooldown; + // Every rate-limited refusal parks its resource, notably the primary form + // GitHub reports as 403 with spent quota headers rather than as 429. Only + // the body separates that from an authorization failure, which must stay + // unparked so a credential problem still surfaces immediately. + const blockedUntil = secondaryLimited + ? undefined + : rateLimited + ? refusedUntil + : retryAfter !== undefined ? now + retryAfter * 1000 : undefined; if (secondaryLimited) { const accountKey = GitHubRequestQueue.accountKey(account); - const accountBlockedUntil = retryAfter !== undefined - ? this._scheduler.now() + retryAfter * 1000 - : resetSeconds !== undefined ? resetSeconds * 1000 : this._accountBlockedUntil.get(accountKey); - if (accountBlockedUntil !== undefined) { - this._accountBlockedUntil.set(accountKey, accountBlockedUntil); - } + // GitHub asks clients that hit a secondary limit to wait at least a + // minute when it gives no usable hint, and the refusal parks the + // whole account rather than only the resource that observed it. + this._accountBlockedUntil.set(accountKey, Math.max(refusedUntil, this._accountBlockedUntil.get(accountKey) ?? 0)); } this._states.set(key, { limit: parseNumber(response.headers.get('x-ratelimit-limit')) ?? previous?.limit, - remaining: parseNumber(response.headers.get('x-ratelimit-remaining')) ?? previous?.remaining, + remaining: remaining ?? previous?.remaining, used: parseNumber(response.headers.get('x-ratelimit-used')) ?? previous?.used, resetAt: resetSeconds !== undefined ? resetSeconds * 1000 : previous?.resetAt, blockedUntil, @@ -95,10 +113,15 @@ export class GitHubRateLimitCoordinator extends Disposable { markGraphQLRateLimited(account: GitHubAccountHandle): void { const key = this._key(account, 'graphql'); const previous = this._states.get(key); + const now = this._scheduler.now(); this._states.set(key, { ...previous, remaining: 0, - blockedUntil: previous?.resetAt ?? this._scheduler.now() + 60_000, + // The retained reset can belong to a window that has already closed, + // and a refusal must park the caller rather than retry at once. + blockedUntil: previous?.resetAt !== undefined && previous.resetAt > now + ? previous.resetAt + : now + unhintedRateLimitCooldown, }); } @@ -144,9 +167,18 @@ function parseSeconds(value: string | null, now: number): number | undefined { return Number.isFinite(date) ? Math.max(0, Math.ceil((date - now) / 1000)) : undefined; } -function isSecondaryRateLimit(status: number, body: string | undefined): boolean { - if (status !== 403 && status !== 429) { - return false; +/** + * Whether GitHub refused the request for rate limiting. Primary exhaustion is + * reported as 403 with the quota headers rather than as 429, and only the body + * tells it apart from an authorization failure. + */ +function isRateLimited(status: number, body: string | undefined): boolean { + if (status === 429) { + return true; } + return status === 403 && (body?.toLowerCase().includes('rate limit') ?? false); +} + +function isSecondaryRateLimit(body: string | undefined): boolean { return body?.toLowerCase().includes('secondary rate limit') ?? false; } diff --git a/src/vs/platform/github/common/githubService.ts b/src/vs/platform/github/common/githubService.ts index ec49f4907b4..738bfd81141 100644 --- a/src/vs/platform/github/common/githubService.ts +++ b/src/vs/platform/github/common/githubService.ts @@ -49,8 +49,8 @@ export class GitHubService extends Disposable implements IGitHubService { this._logService.debug('[GitHubService] Initializing reusable GitHub service'); this.endpoint = options.endpoint; this.transport = this._register(new GitHubTransport(options.fetch, undefined, false, this._logService)); - this.credentials = this._register(new GitHubCredentialService(this.transport, options.tokenProvider, options.endpoint, this._logService)); - this.capabilities = this._register(new GitHubHostCapabilitiesService(this.transport, options.endpoint, this._logService)); + this.credentials = this._register(new GitHubCredentialService(undefined, undefined, this.transport, options.tokenProvider, options.endpoint, this._logService)); + this.capabilities = this._register(new GitHubHostCapabilitiesService(undefined, undefined, this.transport, options.endpoint, this._logService)); const pullRequestQuery = new PullRequestQueryService(this.transport, this.capabilities, options.endpoint, this._logService); this.pullRequests = this._register(new PullRequestResourceService( diff --git a/src/vs/platform/github/common/pullRequestResourceService.ts b/src/vs/platform/github/common/pullRequestResourceService.ts index ba626566835..faaaf350023 100644 --- a/src/vs/platform/github/common/pullRequestResourceService.ts +++ b/src/vs/platform/github/common/pullRequestResourceService.ts @@ -28,6 +28,7 @@ import { PullRequestSubscriptionOptions, } from './githubPullRequestService.js'; import { GitHubCredential, GitHubCredentialInvalidation, IGitHubCredentials } from './githubCredentialService.js'; +import { GitHubBackoffPolicy, gitHubBackoffDelay } from './githubBackoff.js'; import { IGitHubScheduler, systemGitHubScheduler } from './githubScheduler.js'; import { GitHubRequestError } from './githubTransport.js'; import { EffectivePullRequestFragmentInterest, pullRequestOptionsForFragment, unionPullRequestInterests } from './pullRequestInterests.js'; @@ -71,8 +72,7 @@ export interface PullRequestPollingPolicy { readonly mergeabilityVisible: number; readonly mergeabilityBackground: number; readonly participants: number; - readonly failureRetryBase: number; - readonly failureRetryMaximum: number; + readonly failureBackoff: GitHubBackoffPolicy; readonly jitter: number; } @@ -90,8 +90,7 @@ const defaultPollingPolicy: PullRequestPollingPolicy = { mergeabilityVisible: 30_000, mergeabilityBackground: 120_000, participants: 300_000, - failureRetryBase: 30_000, - failureRetryMaximum: 300_000, + failureBackoff: { immediateRetries: 0, base: 30_000, maximum: 300_000, jitter: 5_000 }, jitter: 5_000, }; @@ -673,8 +672,7 @@ export class PullRequestResourceService extends Disposable implements IPullReque } const failures = (entry.failureCounts.get(fragment) ?? 0) + 1; entry.failureCounts.set(fragment, failures); - const delay = Math.min(this._policy.failureRetryBase * 2 ** (failures - 1), this._policy.failureRetryMaximum); - this._scheduleFragment(entry, fragment, this._clock.now() + delay + this._clock.jitter(this._policy.jitter)); + this._scheduleFragment(entry, fragment, this._clock.now() + gitHubBackoffDelay(this._policy.failureBackoff, this._clock, failures)); } private _pollDelay(entry: PullRequestEntry, fragment: PullRequestFragment, interest: EffectivePullRequestFragmentInterest): number | undefined { diff --git a/src/vs/platform/github/test/node/githubCredentialService.test.ts b/src/vs/platform/github/test/node/githubCredentialService.test.ts index 29ba6b21ec8..c6e1c761f71 100644 --- a/src/vs/platform/github/test/node/githubCredentialService.test.ts +++ b/src/vs/platform/github/test/node/githubCredentialService.test.ts @@ -8,15 +8,41 @@ import { Emitter } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { GitHubCredentialService } from '../../common/githubCredentialService.js'; +import { GitHubBackoffPolicy } from '../../common/githubBackoff.js'; import { GitHubRequestError, GitHubTransport } from '../../common/githubTransport.js'; import { IGitHubTokenProvider } from '../../common/githubTypes.js'; +import { FakeGitHubScheduler } from './fakeGitHubScheduler.js'; import { nodeFetch } from './nodeFetch.js'; import { gitHubDisconnectResponse, gitHubJsonResponse, gitHubRestStep, ProgrammableGitHubServer } from './programmableGitHubServer.js'; +/** Jitter-free so every asserted delay is exact. */ +const testBackoffPolicy: GitHubBackoffPolicy = { + immediateRetries: 1, + base: 1_000, + maximum: 8_000, + decay: 60_000, + jitter: 0, +}; + function signal(): AbortSignal { return new AbortController().signal; } +/** Lets every pending continuation reach the scheduler before time is advanced. */ +function flush(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)); +} + +function unreachableUserSteps(count: number): readonly ReturnType[] { + // A failed identity resolution costs two requests because the transport + // retries an unreachable GET once before giving up. + return Array.from({ length: count * 2 }, () => gitHubRestStep({ method: 'GET', path: '/user', response: gitHubDisconnectResponse() })); +} + +function resolvedUserSteps(count: number): readonly ReturnType[] { + return Array.from({ length: count }, () => gitHubRestStep({ method: 'GET', path: '/user', response: gitHubJsonResponse({ id: 101 }) })); +} + class TestTokenProvider extends Disposable implements IGitHubTokenProvider { private readonly _onDidChangeToken = this._register(new Emitter()); @@ -24,6 +50,14 @@ class TestTokenProvider extends Disposable implements IGitHubTokenProvider { readonly invalidatedTokens: string[] = []; private _token: string | undefined; + /** + * `retainInvalidated` models the agent host, whose provider cannot drop a + * token, so a refusal there leaves the very same credential in place. + */ + constructor(private readonly _retainInvalidated = false) { + super(); + } + getToken(): string | undefined { return this._token; } @@ -35,7 +69,7 @@ class TestTokenProvider extends Disposable implements IGitHubTokenProvider { invalidateToken(token: string): void { this.invalidatedTokens.push(token); - if (this._token === token) { + if (!this._retainInvalidated && this._token === token) { this._token = undefined; } } @@ -62,7 +96,7 @@ suite('GitHubCredentialService', () => { const endpoint = server.createEndpointService(); const tokenProvider = disposables.add(new TestTokenProvider()); const transport = disposables.add(new GitHubTransport(nodeFetch)); - const credentials = disposables.add(new GitHubCredentialService(transport, tokenProvider, endpoint)); + const credentials = disposables.add(new GitHubCredentialService(undefined, undefined, transport, tokenProvider, endpoint)); tokenProvider.setToken('one'); const first = await credentials.getCredential(signal()); @@ -97,7 +131,7 @@ suite('GitHubCredentialService', () => { const endpoint = server.createEndpointService(); const tokenProvider = disposables.add(new TestTokenProvider()); const transport = disposables.add(new GitHubTransport(nodeFetch)); - const credentials = disposables.add(new GitHubCredentialService(transport, tokenProvider, endpoint)); + const credentials = disposables.add(new GitHubCredentialService(undefined, undefined, transport, tokenProvider, endpoint)); tokenProvider.setToken('one'); const credential = await credentials.getCredential(signal()); await transport.rest(credential.account, credential.token, { method: 'GET', url: `${server.apiBaseUrl}/repos/o/r/one` }, signal()); @@ -132,7 +166,7 @@ suite('GitHubCredentialService', () => { const tokenProvider = disposables.add(new TestTokenProvider()); tokenProvider.setToken('one'); const transport = disposables.add(new GitHubTransport(nodeFetch)); - const credentials = disposables.add(new GitHubCredentialService(transport, tokenProvider, server.createEndpointService())); + const credentials = disposables.add(new GitHubCredentialService(undefined, undefined, transport, tokenProvider, server.createEndpointService())); await assert.rejects(() => credentials.getCredential(signal()), error => error instanceof GitHubRequestError && error.kind === 'network'); @@ -159,7 +193,7 @@ suite('GitHubCredentialService', () => { ); const tokenProvider = disposables.add(new TestTokenProvider()); const transport = disposables.add(new GitHubTransport(nodeFetch)); - const credentials = disposables.add(new GitHubCredentialService(transport, tokenProvider, server.createEndpointService())); + const credentials = disposables.add(new GitHubCredentialService(undefined, undefined, transport, tokenProvider, server.createEndpointService())); tokenProvider.setToken('one'); const previous = await credentials.getCredential(signal()); @@ -181,4 +215,110 @@ suite('GitHubCredentialService', () => { server.assertSatisfied(); }); }); + + test('delays identity resolution while GitHub keeps failing the same credential', async () => { + await withServer(async server => { + server.enqueue( + ...unreachableUserSteps(2), + ...resolvedUserSteps(1), + ); + const scheduler = disposables.add(new FakeGitHubScheduler({ now: 0 })); + const tokenProvider = disposables.add(new TestTokenProvider()); + tokenProvider.setToken('one'); + const transport = disposables.add(new GitHubTransport(nodeFetch)); + const credentials = disposables.add(new GitHubCredentialService(scheduler, testBackoffPolicy, transport, tokenProvider, server.createEndpointService())); + + await assert.rejects(() => credentials.getCredential(signal())); + await assert.rejects(() => credentials.getCredential(signal())); + const delayed = credentials.getCredential(signal()); + await flush(); + const requestsWhileDelayed = server.requests.length; + const armedDelay = scheduler.nextDueTime; + scheduler.flushAll(); + const recovered = await delayed; + + assert.deepStrictEqual({ + requestsWhileDelayed, + armedDelay, + requestCount: server.requests.length, + account: recovered.account, + }, { + requestsWhileDelayed: 4, + armedDelay: 1_000, + requestCount: 5, + account: { host: new URL(server.apiBaseUrl).host, accountId: '101' }, + }); + server.assertSatisfied(); + }); + }); + + test('escalates while GitHub refuses a credential whose identity call still resolves', async () => { + await withServer(async server => { + // The shape an authentication outage actually takes: `/user` answers + // but every real request is refused, and the host cannot drop the + // token. Each refusal must cost more than the last, or the + // subscribers that re-ask on invalidation spin with no delay at all. + server.enqueue(...resolvedUserSteps(4)); + const scheduler = disposables.add(new FakeGitHubScheduler({ now: 0 })); + const tokenProvider = disposables.add(new TestTokenProvider(true)); + tokenProvider.setToken('one'); + const transport = disposables.add(new GitHubTransport(nodeFetch)); + const credentials = disposables.add(new GitHubCredentialService(scheduler, testBackoffPolicy, transport, tokenProvider, server.createEndpointService())); + + const delays: number[] = []; + for (let round = 0; round < 4; round++) { + const startedAt = scheduler.now(); + const pending = credentials.getCredential(signal()); + await flush(); + scheduler.flushAll(); + const credential = await pending; + delays.push(scheduler.now() - startedAt); + credentials.handleRequestError(credential, new GitHubRequestError('Bad credentials', 'authentication', 401)); + } + + assert.deepStrictEqual({ delays, requestCount: server.requests.length }, { + delays: [0, 0, 1_000, 2_000], + requestCount: 4, + }); + server.assertSatisfied(); + }); + }); + + test('resolves without delay once a new credential replaces the failing one', async () => { + await withServer(async server => { + server.enqueue( + ...unreachableUserSteps(2), + gitHubRestStep({ method: 'GET', path: '/user', response: gitHubJsonResponse({ id: 202 }) }), + ); + const scheduler = disposables.add(new FakeGitHubScheduler({ now: 0 })); + const tokenProvider = disposables.add(new TestTokenProvider()); + tokenProvider.setToken('one'); + const transport = disposables.add(new GitHubTransport(nodeFetch)); + const credentials = disposables.add(new GitHubCredentialService(scheduler, testBackoffPolicy, transport, tokenProvider, server.createEndpointService())); + + await assert.rejects(() => credentials.getCredential(signal())); + await assert.rejects(() => credentials.getCredential(signal())); + // Parks on the delay the two failures established, and must abandon + // it rather than resolve the credential that has since been replaced. + const abandoned = assert.rejects( + () => credentials.getCredential(signal()), + error => error instanceof GitHubRequestError && error.kind === 'authentication', + ); + await flush(); + tokenProvider.setToken('two'); + const recovered = await credentials.getCredential(signal()); + await abandoned; + + assert.deepStrictEqual({ + account: recovered.account, + requestCount: server.requests.length, + pendingDelays: scheduler.pendingCount, + }, { + account: { host: new URL(server.apiBaseUrl).host, accountId: '202' }, + requestCount: 5, + pendingDelays: 0, + }); + server.assertSatisfied(); + }); + }); }); diff --git a/src/vs/platform/github/test/node/githubHostCapabilitiesService.test.ts b/src/vs/platform/github/test/node/githubHostCapabilitiesService.test.ts index 5bdb5b47214..7facd40a6fb 100644 --- a/src/vs/platform/github/test/node/githubHostCapabilitiesService.test.ts +++ b/src/vs/platform/github/test/node/githubHostCapabilitiesService.test.ts @@ -9,6 +9,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import { GitHubHostCapabilitiesService } from '../../common/githubHostCapabilitiesService.js'; import { GitHubTransport } from '../../common/githubTransport.js'; +import { FakeGitHubScheduler } from './fakeGitHubScheduler.js'; import { nodeFetch } from './nodeFetch.js'; import { gitHubGraphQLResponse, gitHubGraphQLStep, ProgrammableGitHubServer } from './programmableGitHubServer.js'; @@ -43,7 +44,7 @@ suite('GitHubHostCapabilitiesService', () => { }), })); const transport = disposables.add(new GitHubTransport(nodeFetch)); - const service = disposables.add(new GitHubHostCapabilitiesService(transport, server.createEndpointService())); + const service = disposables.add(new GitHubHostCapabilitiesService(undefined, undefined, transport, server.createEndpointService())); const signal = new AbortController().signal; const credential = { account: { host: new URL(server.apiBaseUrl).host, accountId: '101' }, @@ -84,7 +85,7 @@ suite('GitHubHostCapabilitiesService', () => { }), })); const transport = disposables.add(new GitHubTransport(nodeFetch)); - const service = disposables.add(new GitHubHostCapabilitiesService(transport, server.createEndpointService())); + const service = disposables.add(new GitHubHostCapabilitiesService(undefined, undefined, transport, server.createEndpointService())); const signal = new AbortController().signal; await service.getCapabilities({ @@ -108,7 +109,7 @@ suite('GitHubHostCapabilitiesService', () => { response: gitHubGraphQLResponse(undefined, [{ message: 'Field does not exist', type: 'VALIDATION' }]), })); const transport = disposables.add(new GitHubTransport(nodeFetch)); - const service = disposables.add(new GitHubHostCapabilitiesService(transport, server.createEndpointService())); + const service = disposables.add(new GitHubHostCapabilitiesService(undefined, undefined, transport, server.createEndpointService())); const signal = new AbortController().signal; const result = await service.getCapabilities({ @@ -139,7 +140,7 @@ suite('GitHubHostCapabilitiesService', () => { })); const transport = disposables.add(new GitHubTransport(nodeFetch)); const logService = disposables.add(new RecordingLogService()); - const service = disposables.add(new GitHubHostCapabilitiesService(transport, server.createEndpointService(), logService)); + const service = disposables.add(new GitHubHostCapabilitiesService(undefined, undefined, transport, server.createEndpointService(), logService)); const signal = new AbortController().signal; const result = await service.getCapabilities({ @@ -170,7 +171,7 @@ suite('GitHubHostCapabilitiesService', () => { }), })); const transport = disposables.add(new GitHubTransport(nodeFetch)); - const service = disposables.add(new GitHubHostCapabilitiesService(transport, server.createEndpointService())); + const service = disposables.add(new GitHubHostCapabilitiesService(undefined, undefined, transport, server.createEndpointService())); const signal = new AbortController().signal; const result = await service.getCapabilities({ @@ -191,7 +192,7 @@ suite('GitHubHostCapabilitiesService', () => { }); }); - test('retries capability probing after a transient GraphQL error', async () => { + test('reuses a degraded probe result before retrying a transient GraphQL error', async () => { await withServer(async server => { server.enqueue( gitHubGraphQLStep({ @@ -205,8 +206,9 @@ suite('GitHubHostCapabilitiesService', () => { }), }), ); + const scheduler = disposables.add(new FakeGitHubScheduler({ now: 0 })); const transport = disposables.add(new GitHubTransport(nodeFetch)); - const service = disposables.add(new GitHubHostCapabilitiesService(transport, server.createEndpointService())); + const service = disposables.add(new GitHubHostCapabilitiesService(scheduler, undefined, transport, server.createEndpointService())); const signal = new AbortController().signal; const credential = { account: { host: new URL(server.apiBaseUrl).host, accountId: '101' }, @@ -216,20 +218,31 @@ suite('GitHubHostCapabilitiesService', () => { }; const transient = await service.getCapabilities(credential, undefined, signal); + // An uncacheable result must not be re-probed on every lookup: the + // fragments that ask still succeed on REST fallbacks, so nothing else + // would throttle the extra introspection query. + const throttled = await service.getCapabilities(credential, undefined, signal); + const requestsWhileThrottled = server.requests.length; + scheduler.advanceBy(65_000); const recovered = await service.getCapabilities(credential, undefined, signal); + const unavailable = { + graphql: false, + mergeQueue: false, + internalMergeStatus: false, + reviewThreads: false, + checkContextRequiredness: false, + }; assert.deepStrictEqual({ transient, + throttled, + requestsWhileThrottled, recovered, requestCount: server.requests.length, }, { - transient: { - graphql: false, - mergeQueue: false, - internalMergeStatus: false, - reviewThreads: false, - checkContextRequiredness: false, - }, + transient: unavailable, + throttled: unavailable, + requestsWhileThrottled: 1, recovered: { graphql: true, mergeQueue: false, @@ -243,6 +256,49 @@ suite('GitHubHostCapabilitiesService', () => { }); }); + test('re-probes a degraded host as soon as a new credential arrives', async () => { + await withServer(async server => { + server.enqueue( + gitHubGraphQLStep({ + // A refusal that belongs to the credential, not the host. + response: gitHubGraphQLResponse(undefined, [{ message: 'Resource protected by organization SAML enforcement', type: 'FORBIDDEN' }]), + }), + gitHubGraphQLStep({ + response: gitHubGraphQLResponse({ + pullRequest: { fields: [{ name: 'reviewThreads' }] }, + repository: { fields: [] }, + requirableByPullRequest: null, + }), + }), + ); + const scheduler = disposables.add(new FakeGitHubScheduler({ now: 0 })); + const transport = disposables.add(new GitHubTransport(nodeFetch)); + const service = disposables.add(new GitHubHostCapabilitiesService(scheduler, undefined, transport, server.createEndpointService())); + const signal = new AbortController().signal; + const account = { host: new URL(server.apiBaseUrl).host, accountId: '101' }; + + const refused = await service.getCapabilities({ account, token: 'stale', generation: 1, signal }, undefined, signal); + // Authorizing the credential must not leave the user pinned to the + // REST fallbacks the refusal produced for the rest of the window. + const reauthenticated = await service.getCapabilities({ account, token: 'fresh', generation: 2, signal }, undefined, signal); + + assert.deepStrictEqual({ + refusedGraphql: refused.graphql, + reauthenticatedGraphql: reauthenticated.graphql, + reauthenticatedReviewThreads: reauthenticated.reviewThreads, + elapsed: scheduler.now(), + requestCount: server.requests.length, + }, { + refusedGraphql: false, + reauthenticatedGraphql: true, + reauthenticatedReviewThreads: true, + elapsed: 0, + requestCount: 2, + }); + server.assertSatisfied(); + }); + }); + test('cancelling one capability waiter does not cancel another', async () => { await withServer(async server => { const requestSeen = new DeferredPromise(); @@ -257,7 +313,7 @@ suite('GitHubHostCapabilitiesService', () => { }), })); const transport = disposables.add(new GitHubTransport(nodeFetch)); - const service = disposables.add(new GitHubHostCapabilitiesService(transport, server.createEndpointService())); + const service = disposables.add(new GitHubHostCapabilitiesService(undefined, undefined, transport, server.createEndpointService())); const credentialSignal = new AbortController().signal; const credential = { account: { host: new URL(server.apiBaseUrl).host, accountId: '101' }, diff --git a/src/vs/platform/github/test/node/githubQueryService.test.ts b/src/vs/platform/github/test/node/githubQueryService.test.ts index 3913b90d9cb..d73425b32e1 100644 --- a/src/vs/platform/github/test/node/githubQueryService.test.ts +++ b/src/vs/platform/github/test/node/githubQueryService.test.ts @@ -31,6 +31,7 @@ const policy: GitHubEntityPollingPolicy = { maximumDormantEntries: 2, visible: 10, background: 100, + failureBackoff: { immediateRetries: 0, base: 30, maximum: 300, jitter: 0 }, jitter: 0, }; @@ -805,6 +806,75 @@ suite('GitHubQueryService', () => { server.assertSatisfied(); }); }); + + test('spaces out retries the longer an entity keeps failing', async () => { + await withServer(async server => { + const { clock, ref, service } = setup(server); + server.enqueue(...Array.from({ length: 3 }, () => gitHubRestStep({ + method: 'GET', + path: '/repos/octo/repo', + response: gitHubJsonResponse({ message: 'Not Found' }, { status: 404 }), + }))); + const subscription = service.subscribeRepository(ref, { priority: 'visible' }); + + await assert.rejects(() => subscription.refresh()); + const firstRetryAt = clock.nextDueTime; + clock.advanceTo(firstRetryAt!); + await assert.rejects(() => subscription.refresh()); + const secondRetryAt = clock.nextDueTime; + clock.advanceTo(secondRetryAt!); + await assert.rejects(() => subscription.refresh()); + + // The visible cadence is 10ms, so a failure must never be retried at it. + assert.deepStrictEqual({ + firstRetryAt, + secondRetryAt, + thirdRetryAt: clock.nextDueTime, + requestCount: server.requests.length, + }, { + firstRetryAt: 30, + secondRetryAt: 90, + thirdRetryAt: 210, + requestCount: 3, + }); + subscription.dispose(); + server.assertSatisfied(); + }); + }); + + test('jitters a failure retry that the poll cadence, not the backoff, decides', async () => { + await withServer(async server => { + // A background entity polls far slower than the first backoff steps, + // so the cadence wins. It still has to be spread: credential + // invalidation and rate-limit releases fail whole batches at the very + // same instant, and an unjittered retry keeps them phase-locked. + const jittered = disposables.add(new FakeGitHubScheduler({ now: 0, jitterValues: [7] })); + const credentials = disposables.add(new TestCredentialService({ host: new URL(server.apiBaseUrl).host, accountId: '101' })); + const transport = disposables.add(new GitHubTransport(nodeFetch)); + const service = disposables.add(new GitHubQueryService( + jittered, + { ...policy, failureBackoff: { ...policy.failureBackoff, jitter: 10 } }, + credentials, + transport, + server.createEndpointService(), + new TestCapabilitiesService(), + new NullLogService(), + )); + server.enqueue(gitHubRestStep({ + method: 'GET', + path: '/repos/octo/repo', + response: gitHubJsonResponse({ message: 'Not Found' }, { status: 404 }), + })); + const subscription = service.subscribeRepository({ host: new URL(server.apiBaseUrl).host, accountId: '101', owner: 'octo', repo: 'repo' }, { priority: 'background' }); + + await assert.rejects(() => subscription.refresh()); + + // The background cadence is 100ms and the first backoff step is 30ms. + assert.strictEqual(jittered.nextDueTime, 107); + subscription.dispose(); + server.assertSatisfied(); + }); + }); }); function signal(): AbortSignal { diff --git a/src/vs/platform/github/test/node/githubTransport.test.ts b/src/vs/platform/github/test/node/githubTransport.test.ts index 48edc2e2d7c..4b40dc67371 100644 --- a/src/vs/platform/github/test/node/githubTransport.test.ts +++ b/src/vs/platform/github/test/node/githubTransport.test.ts @@ -476,6 +476,122 @@ suite('GitHubTransport', () => { }); }); + test('parks the account when a secondary rate limit gives no usable retry hint', async () => { + await withServer(async server => { + const scheduler = new FakeGitHubScheduler({ now: 1_000_000 }); + const transport = disposables.add(new GitHubTransport(nodeFetch, scheduler)); + server.enqueue( + gitHubRestStep({ + method: 'GET', + path: '/repos/o/r/unhinted', + response: gitHubRateLimitResponse({ status: 403, resource: 'core' }), + }), + gitHubRestStep({ method: 'GET', path: '/repos/o/r/afterUnhinted', response: gitHubJsonResponse({ ok: true }) }), + gitHubRestStep({ + method: 'GET', + path: '/repos/o/r/stale', + // A secondary limit often reports the primary quota window, + // which can already have elapsed. + response: gitHubRateLimitResponse({ status: 403, resource: 'core', resetAt: 1_000 }), + }), + gitHubRestStep({ method: 'GET', path: '/repos/o/r/afterStale', response: gitHubJsonResponse({ ok: true }) }), + gitHubRestStep({ + method: 'GET', + path: '/repos/o/r/primaryWindow', + // A secondary limit reports the primary quota window, which + // is far in the future while that quota is still unspent. + response: gitHubRateLimitResponse({ status: 403, resource: 'core', resetAt: 4_600_000, remaining: 4_000 }), + }), + gitHubRestStep({ method: 'GET', path: '/repos/o/r/afterPrimaryWindow', response: gitHubJsonResponse({ ok: true }) }), + ); + + const observed: number[] = []; + for (const [limited, after] of [['unhinted', 'afterUnhinted'], ['stale', 'afterStale'], ['primaryWindow', 'afterPrimaryWindow']]) { + await assert.rejects( + () => transport.rest(accountA, 'token-a', { method: 'GET', url: `${server.apiBaseUrl}/repos/o/r/${limited}` }, signal()), + error => error instanceof GitHubRequestError && error.kind === 'rateLimit', + ); + const startedAt = scheduler.now(); + const pending = transport.rest(accountA, 'token-a', { method: 'GET', url: `${server.apiBaseUrl}/repos/o/r/${after}` }, signal()); + await Promise.resolve(); + scheduler.flushAll(); + await pending; + observed.push(scheduler.now() - startedAt); + } + + assert.deepStrictEqual(observed, [60_000, 60_000, 60_000]); + server.assertSatisfied(); + }); + }); + + test('parks a primary rate limit that GitHub reports as 403 rather than 429', async () => { + await withServer(async server => { + const scheduler = new FakeGitHubScheduler({ now: 1_000_000 }); + const transport = disposables.add(new GitHubTransport(nodeFetch, scheduler)); + server.enqueue( + gitHubRestStep({ + method: 'GET', + path: '/repos/o/r/spentNoReset', + // Primary exhaustion carries no `retry-after`, and a proxy can + // strip the reset, leaving nothing to wait on but the floor. + response: gitHubRateLimitResponse({ status: 403, resource: 'core', remaining: 0, message: 'API rate limit exceeded for user ID 1.' }), + }), + gitHubRestStep({ method: 'GET', path: '/repos/o/r/afterSpentNoReset', response: gitHubJsonResponse({ ok: true }) }), + gitHubRestStep({ + method: 'GET', + path: '/repos/o/r/spentWithReset', + response: gitHubRateLimitResponse({ status: 403, resource: 'core', remaining: 0, resetAt: 1_180_000, message: 'API rate limit exceeded for user ID 1.' }), + }), + gitHubRestStep({ method: 'GET', path: '/repos/o/r/afterSpentWithReset', response: gitHubJsonResponse({ ok: true }) }), + ); + + const observed: number[] = []; + for (const [limited, after] of [['spentNoReset', 'afterSpentNoReset'], ['spentWithReset', 'afterSpentWithReset']]) { + await assert.rejects( + () => transport.rest(accountA, 'token-a', { method: 'GET', url: `${server.apiBaseUrl}/repos/o/r/${limited}` }, signal()), + error => error instanceof GitHubRequestError && error.kind === 'rateLimit', + ); + const startedAt = scheduler.now(); + const pending = transport.rest(accountA, 'token-a', { method: 'GET', url: `${server.apiBaseUrl}/repos/o/r/${after}` }, signal()); + await Promise.resolve(); + scheduler.flushAll(); + await pending; + observed.push(scheduler.now() - startedAt); + } + + // The floor when nothing usable was given, then the remainder of the + // absolute reset window (1_180_000) from where the first park left off. + assert.deepStrictEqual(observed, [60_000, 120_000]); + server.assertSatisfied(); + }); + }); + + test('does not park an authorization failure that merely shares the 403 status', async () => { + await withServer(async server => { + const scheduler = new FakeGitHubScheduler({ now: 1_000_000 }); + const transport = disposables.add(new GitHubTransport(nodeFetch, scheduler)); + server.enqueue( + gitHubRestStep({ + method: 'GET', + path: '/repos/o/r/forbidden', + response: gitHubJsonResponse({ message: 'Resource not accessible by integration' }, { status: 403 }), + }), + gitHubRestStep({ method: 'GET', path: '/repos/o/r/afterForbidden', response: gitHubJsonResponse({ ok: true }) }), + ); + + await assert.rejects( + () => transport.rest(accountA, 'token-a', { method: 'GET', url: `${server.apiBaseUrl}/repos/o/r/forbidden` }, signal()), + error => error instanceof GitHubRequestError && error.kind === 'authorization', + ); + const startedAt = scheduler.now(); + await transport.rest(accountA, 'token-a', { method: 'GET', url: `${server.apiBaseUrl}/repos/o/r/afterForbidden` }, signal()); + + // A credential problem must surface at once rather than being parked. + assert.deepStrictEqual({ waited: scheduler.now() - startedAt, pending: scheduler.pendingCount }, { waited: 0, pending: 0 }); + server.assertSatisfied(); + }); + }); + test('GraphQL RATE_LIMITED errors establish shared account backoff', async () => { await withServer(async server => { const scheduler = new FakeGitHubScheduler({ now: 1_000 }); diff --git a/src/vs/platform/github/test/node/pullRequestResourceService.test.ts b/src/vs/platform/github/test/node/pullRequestResourceService.test.ts index c7ddb661876..ab2ed6a776d 100644 --- a/src/vs/platform/github/test/node/pullRequestResourceService.test.ts +++ b/src/vs/platform/github/test/node/pullRequestResourceService.test.ts @@ -33,8 +33,7 @@ const policy: PullRequestPollingPolicy = { mergeabilityVisible: 20, mergeabilityBackground: 200, participants: 300, - failureRetryBase: 5, - failureRetryMaximum: 20, + failureBackoff: { immediateRetries: 0, base: 5, maximum: 20, jitter: 0 }, jitter: 0, }; From 1faca48a3e7350ae7ff53582693c34d34fce7282 Mon Sep 17 00:00:00 2001 From: joshspicer <23246594+joshspicer@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:33:40 -0700 Subject: [PATCH 013/116] Add valid permissions presets to mock-policy-server (#332590) * Add valid permissions presets to mock-policy-server Add five new managedSettings presets exercising the SDK's managed permissions schema (deny/ask/allow rule lists and both disableBypassPermissionsMode values), validated against copilot-agent-runtime's managed-settings-schema.json and rule parser: - allow-auto-only: disableBypassPermissionsMode='allow-auto-only' - deny-dangerous-commands: deny list blocking shell/write/domain rules - ask-before-publish: ask list requiring approval without blocking - lockdown-allowlist: allow list intersection combined with deny - Clarified the existing disable-bypass-permissions description Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add workspace-scoped permission preset Add examples using the managed permission syntax where a single leading slash scopes file rules to the workspace root. Also correct existing examples that described workspace-scoped rules as system paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clarify permission preset scopes Document both managed bypass permission enum values and clarify that Write(~/**) covers the user home directory, including workspaces beneath it, not every path outside the workspace. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../github-managed-settings.md | 2 +- scripts/mock-policy-server/endpoints.ts | 83 ++++++++++++++++++- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/.github/skills/policy-and-managed-settings/github-managed-settings.md b/.github/skills/policy-and-managed-settings/github-managed-settings.md index ff830579601..55020bc8289 100644 --- a/.github/skills/policy-and-managed-settings/github-managed-settings.md +++ b/.github/skills/policy-and-managed-settings/github-managed-settings.md @@ -81,7 +81,7 @@ the schema's nested | Schema property (path) | Type in schema | Composition (`x-composition.strategy`) | |------------------------|----------------|----------------------------------------| -| `permissions.disableBypassPermissionsMode` | string enum `"disable"` | most-restrictive-wins (sticky once set) | +| `permissions.disableBypassPermissionsMode` | string enum `"disable"` \| `"allow-auto-only"` | most-restrictive-wins (sticky once set) | | `model` | string (`auto`, a model family name, or a full model id) | — | | `permissions.model` | string (legacy location for `model`) | — | | `forceRemoteSettingsRefresh` | boolean | MDM wins; controls the server cache rather than a configuration setting | diff --git a/scripts/mock-policy-server/endpoints.ts b/scripts/mock-policy-server/endpoints.ts index ae3c880c49f..8174804de39 100644 --- a/scripts/mock-policy-server/endpoints.ts +++ b/scripts/mock-policy-server/endpoints.ts @@ -86,7 +86,7 @@ declare var MOCK_POLICY_ENDPOINTS: EndpointDef[]; { id: 'disable-bypass-permissions', label: 'Disable bypass permissions', - description: 'Disables bypass permissions mode.', + description: 'Blocks all escalation to bypass-permissions ("allow-all"/"yolo") mode, including auto-approval.', status: 200, body: { permissions: { @@ -94,6 +94,87 @@ declare var MOCK_POLICY_ENDPOINTS: EndpointDef[]; } } }, + { + id: 'allow-auto-only', + label: 'Allow auto-approval only', + description: 'Blocks full allow-all bypass but still permits advisory auto-approval (LLM safety recommendations with normal prompt paths).', + status: 200, + body: { + permissions: { + disableBypassPermissionsMode: 'allow-auto-only' + } + } + }, + { + id: 'deny-dangerous-commands', + label: 'Deny dangerous shell/file operations', + description: 'Blocks specific shell commands, workspace-scoped file writes, and a domain outright. A single leading slash means the workspace root in the managed permission syntax.', + status: 200, + body: { + permissions: { + deny: [ + 'Shell(rm -rf *)', + 'Shell(curl *)', + 'Write(/.github/workflows/**)', + 'Domain(evil.example.com)' + ] + } + } + }, + { + id: 'workspace-scoped-paths', + label: 'Workspace-scoped paths', + description: 'Demonstrates paths relative to the workspace root: /src/** and /test/** match only inside the workspace, while /package.json targets that workspace file.', + status: 200, + body: { + permissions: { + ask: [ + 'Write(/src/**)', + 'Write(/test/**)' + ], + deny: [ + 'Write(/package.json)' + ] + } + } + }, + { + id: 'ask-before-publish', + label: 'Ask before publishing or deploying', + description: 'Requires human approval for package publish/deploy commands and writes anywhere under the user home directory, including workspaces located there. It does not cover paths outside the home directory.', + status: 200, + body: { + permissions: { + ask: [ + 'Shell(npm publish *)', + 'Shell(git push *)', + 'Write(~/**)' + ] + } + } + }, + { + id: 'lockdown-allowlist', + label: 'Lockdown: allow only an approved set', + description: 'Intersects with any other managed allow list, so only requests every managed source admits run without prompting. Combine with deny/ask for defense in depth.', + status: 200, + body: { + permissions: { + disableBypassPermissionsMode: 'disable', + allow: [ + 'Read(**)', + 'Shell(git status)', + 'Shell(git diff *)', + 'Domain(github.com)', + 'Domain(*.githubusercontent.com)' + ], + deny: [ + 'Write(/.github/workflows/**)', + 'Write(~/.ssh/**)' + ] + } + } + }, { id: 'model-auto', label: 'Model: auto', From 38ec3d57f91b5645d30fa25d2bf75448e258d46b Mon Sep 17 00:00:00 2001 From: joshspicer <23246594+joshspicer@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:54:07 -0700 Subject: [PATCH 014/116] managed settings: fix claude enablement (#332584) * Agent Host changes for agents/disable-claude-extension-host * agentHost: preserve default Claude provider visibility Treat an absent Claude enablement value as the registered default (enabled), while continuing to hide the provider for an explicit or policy-enforced false value. This keeps lightweight test and embedder configurations compatible with the product default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove temporary policy screenshots Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../platform/agentHost/common/agentService.ts | 5 ++-- .../test/common/agentService.test.ts | 24 ++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 1b1c41cc5c6..3ecabc1c6c4 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -297,13 +297,14 @@ export function getAgentHostCopilotSandboxSettingId(customTerminalToolEnabled: b export const CodexPreferAgentHostEditorSettingId = 'chat.editor.codex.preferAgentHost'; export function affectsAgentHostProviderPreference(event: IConfigurationChangeEvent, isSessionsWindow: boolean): boolean { - return event.affectsConfiguration(isSessionsWindow ? AgentHostCodexAgentEnabledSettingId : CodexPreferAgentHostEditorSettingId); + return event.affectsConfiguration(AgentHostClaudeAgentEnabledSettingId) + || event.affectsConfiguration(isSessionsWindow ? AgentHostCodexAgentEnabledSettingId : CodexPreferAgentHostEditorSettingId); } export function shouldSurfaceLocalAgentHostProvider(provider: AgentProvider, configurationService: IConfigurationService, isSessionsWindow: boolean): boolean { switch (provider) { case CLAUDE_AGENT_PROVIDER_ID: - return true; + return configurationService.getValue(AgentHostClaudeAgentEnabledSettingId) !== false; case CODEX_AGENT_PROVIDER_ID: return configurationService.getValue(isSessionsWindow ? AgentHostCodexAgentEnabledSettingId : CodexPreferAgentHostEditorSettingId) === true; default: diff --git a/src/vs/platform/agentHost/test/common/agentService.test.ts b/src/vs/platform/agentHost/test/common/agentService.test.ts index ec541d729da..fc60fc9e376 100644 --- a/src/vs/platform/agentHost/test/common/agentService.test.ts +++ b/src/vs/platform/agentHost/test/common/agentService.test.ts @@ -8,7 +8,7 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { IConfigurationService } from '../../../configuration/common/configuration.js'; import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, protectedResourcesRequireGitHubCopilotSignIn } from '../../common/agent.js'; -import { AgentHostCodexAgentEnabledSettingId, AgentHostOTelEnvVars, buildAgentHostOTelEnv, CodexPreferAgentHostEditorSettingId, isAgentEnabled, readAgentHostOTelPolicySettings, sanitizeAgentHostOTelPolicySettings, shouldSurfaceLocalAgentHostProvider } from '../../common/agentService.js'; +import { AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostOTelEnvVars, buildAgentHostOTelEnv, CodexPreferAgentHostEditorSettingId, isAgentEnabled, readAgentHostOTelPolicySettings, sanitizeAgentHostOTelPolicySettings, shouldSurfaceLocalAgentHostProvider } from '../../common/agentService.js'; import type { ProtectedResourceMetadata } from '../../common/state/protocol/state.js'; import { buildChatUri, buildDefaultChatUri, resolveChatUri } from '../../common/state/sessionState.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; @@ -76,8 +76,9 @@ suite('shouldSurfaceLocalAgentHostProvider', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('always surfaces Claude and uses window-specific Codex settings', () => { + test('surfaces enabled providers and uses window-specific Codex settings', () => { const configurationService = new TestConfigurationService({ + [AgentHostClaudeAgentEnabledSettingId]: true, [AgentHostCodexAgentEnabledSettingId]: true, [CodexPreferAgentHostEditorSettingId]: true, }); @@ -97,16 +98,33 @@ suite('shouldSurfaceLocalAgentHostProvider', () => { }); }); - test('hides Codex from the Agents window when the provider is disabled', () => { + test('surfaces Claude when the setting is absent, matching its default', () => { + const configurationService = new TestConfigurationService(); + + assert.deepStrictEqual({ + agentsClaude: shouldSurfaceLocalAgentHostProvider('claude', configurationService, true), + editorClaude: shouldSurfaceLocalAgentHostProvider('claude', configurationService, false), + }, { + agentsClaude: true, + editorClaude: true, + }); + }); + + test('hides disabled providers in their governed windows', () => { const configurationService = new TestConfigurationService({ + [AgentHostClaudeAgentEnabledSettingId]: false, [AgentHostCodexAgentEnabledSettingId]: false, [CodexPreferAgentHostEditorSettingId]: true, }); assert.deepStrictEqual({ + agentsClaude: shouldSurfaceLocalAgentHostProvider('claude', configurationService, true), + editorClaude: shouldSurfaceLocalAgentHostProvider('claude', configurationService, false), agentsCodex: shouldSurfaceLocalAgentHostProvider('codex', configurationService, true), editorCodex: shouldSurfaceLocalAgentHostProvider('codex', configurationService, false), }, { + agentsClaude: false, + editorClaude: false, agentsCodex: false, editorCodex: true, }); From 3d079185299158cc8c7428ea1ea67dfeb0e978c6 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:15:56 +0200 Subject: [PATCH 015/116] chat: explain why Agent Merge turned itself off (#332598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chat: explain why Agent Merge turned itself off Agent Merge could stop monitoring a session without saying anything. The controller only wrote an `info` log line, so from the UI the feature simply switched itself off with no explanation. This is easy to hit on a session using `folder` isolation: the controller pins the branch that was checked out when it was enabled, and any later `git checkout` in that shared clone — from the user or another session — trips the branch-changed guard and disables monitoring. Report both transitions in the session transcript instead: - "Agent Merge is on and watching ``." when it captures a branch. - A reason-specific sentence when it stops, e.g. "Agent Merge was turned off because the checked-out branch changed from `` to ``." The notice is dispatched as server state only, so it reaches clients without ever entering the agent's context, and is recorded through `AgentHostLocalTurns` so it survives a reload like other host-injected turns. All ten disable reasons now pair a stable English log detail with a localized notice, so existing log output is unchanged. Also fixes a latent alignment bug in `.progress-container`: `align-items: center` floated the icon to the middle of a wrapped message. It now sits on the first line, which affects tool progress rows too. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Defer mid-turn Agent Merge notices, fix CSS indentation A notice raised while the agent held a turn was appended to that turn and never recorded in `AgentHostLocalTurns`. Provider replay reconstructs the turn from the SDK transcript, which has no record of the host-only part, so the notice disappeared after a reload. This is reachable: the Disable Agent Merge action stays available during an active turn, as does archiving. Starting a standalone turn instead is not an option mid-turn, because the chat reducer replaces `activeTurn` on `ChatTurnStarted` and would displace the running turn. Queue such notices per session and emit them when the session goes idle, so each one owns a durable local turn. Pending notices are dropped when the session is removed. Also fixes two "Bad whitespace indentation" hygiene errors: comment continuation lines in chat.css used spaces rather than tabs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../platform/agentHost/common/agentMerge.ts | 72 +++++++++++ .../meta/agentSystemNotificationMeta.ts | 13 +- .../agentHost/node/agentHostLocalTurns.ts | 19 +++ .../agentHost/node/agentMergeController.ts | 71 ++++++++-- .../platform/agentHost/node/agentService.ts | 85 +++++++++++- .../agentHost/node/agentServiceComposition.ts | 1 + .../node/localCommands/localChatCommand.ts | 10 +- .../test/node/agentMergeController.test.ts | 96 +++++++++++++- .../agentHost/test/node/agentService.test.ts | 90 ++++++++++++- .../agentHost/stateToProgressAdapter.ts | 17 ++- .../chatSystemNotificationContentPart.ts | 7 +- .../chat/browser/widget/media/chat.css | 7 +- .../chat/common/chatService/chatService.ts | 5 + .../stateToProgressAdapter.test.ts | 23 ++++ .../chat/chatAgentMergeNotice.fixture.ts | 121 ++++++++++++++++++ 15 files changed, 604 insertions(+), 33 deletions(-) create mode 100644 src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeNotice.fixture.ts diff --git a/src/vs/platform/agentHost/common/agentMerge.ts b/src/vs/platform/agentHost/common/agentMerge.ts index 6470396fbe2..deb50a323ff 100644 --- a/src/vs/platform/agentHost/common/agentMerge.ts +++ b/src/vs/platform/agentHost/common/agentMerge.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from '../../../nls.js'; +import { appendEscapedMarkdownInlineCode } from '../../../base/common/htmlContent.js'; import { createSchema, schemaProperty } from './agentHostSchema.js'; import { GitHubActor, PullRequestCheck, PullRequestChecks, PullRequestSnapshot } from '../../github/common/githubPullRequestService.js'; import { SessionConfigKey } from './sessionConfigKeys.js'; @@ -198,6 +199,77 @@ export function resolveAgentMergeConfiguration(defaults: AgentMergeConfiguration }; } +/** + * Why Agent Merge stopped monitoring a session. Keeping both strings together + * lets the controller log a stable English detail while the transcript shows a + * localized sentence, without either drifting from the other. + */ +export interface AgentMergeDisableReason { + /** Stable English detail appended to the host log line. */ + readonly log: string; + /** Localized sentence shown to the user in the session transcript. */ + readonly notice: string; +} + +/** Every reason the Agent Merge controller can stop monitoring a session on its own. */ +export const agentMergeDisableReasons = { + sessionArchived: (): AgentMergeDisableReason => ({ + log: 'the session was archived', + notice: localize('agentMerge.disabled.sessionArchived', "Agent Merge was turned off because this session was archived."), + }), + branchChanged: (from: string, to: string): AgentMergeDisableReason => ({ + log: `branch changed from ${from} to ${to}`, + notice: localize( + 'agentMerge.disabled.branchChanged', + "Agent Merge was turned off because the checked-out branch changed from {0} to {1}.", + appendEscapedMarkdownInlineCode(from), + appendEscapedMarkdownInlineCode(to) + ), + }), + branchChangedWhileRefreshing: (): AgentMergeDisableReason => ({ + log: 'the checked-out branch changed while pull request state was refreshing', + notice: localize('agentMerge.disabled.branchChangedWhileRefreshing', "Agent Merge was turned off because the checked-out branch changed while its pull request state was refreshing."), + }), + differentPullRequest: (): AgentMergeDisableReason => ({ + log: 'the session became associated with a different pull request', + notice: localize('agentMerge.disabled.differentPullRequest', "Agent Merge was turned off because this session became associated with a different pull request."), + }), + invalidPullRequestUrl: (): AgentMergeDisableReason => ({ + log: 'the associated pull request URL is invalid', + notice: localize('agentMerge.disabled.invalidPullRequestUrl', "Agent Merge was turned off because the associated pull request URL is invalid."), + }), + differentGitHubHost: (): AgentMergeDisableReason => ({ + log: 'the bound pull request belongs to a different GitHub host than the signed-in account', + notice: localize('agentMerge.disabled.differentGitHubHost', "Agent Merge was turned off because its pull request belongs to a different GitHub host than the signed-in account."), + }), + indeterminate: (minutes: number, reason: string): AgentMergeDisableReason => ({ + log: `the pull request state could not be evaluated for ${minutes} minutes: ${reason}`, + notice: localize('agentMerge.disabled.indeterminate', "Agent Merge was turned off because its pull request state could not be evaluated for {0} minutes.", minutes), + }), + pullRequestClosed: (): AgentMergeDisableReason => ({ + log: 'the pull request is closed or merged', + notice: localize('agentMerge.disabled.pullRequestClosed', "Agent Merge was turned off because its pull request is closed or merged."), + }), + repairBudgetExhausted: (): AgentMergeDisableReason => ({ + log: 'the same pull request blockers remained after repeated repair attempts', + notice: localize('agentMerge.disabled.repairBudgetExhausted', "Agent Merge was turned off because the same pull request blockers remained after repeated repair attempts."), + }), + pullRequestMerged: (): AgentMergeDisableReason => ({ + log: 'the pull request was merged', + notice: localize('agentMerge.disabled.pullRequestMerged', "Agent Merge merged its pull request and turned itself off."), + }), +} as const; + +/** The transcript notice shown once Agent Merge starts watching a branch. */ +export function agentMergeEnabledNotice(branchName: string): string { + return localize('agentMerge.notice.enabled', "Agent Merge is on and watching {0}.", appendEscapedMarkdownInlineCode(branchName)); +} + +/** The transcript notice shown when the user, rather than the controller, turns Agent Merge off. */ +export function agentMergeDisabledNotice(): string { + return localize('agentMerge.notice.disabled', "Agent Merge was turned off for this session."); +} + export function readAgentMergeSessionState(values: Record | undefined): AgentMergeSessionState | undefined { const value = values?.[SessionConfigKey.AgentMerge]; if (!isRecord(value) || typeof value.enabled !== 'boolean') { diff --git a/src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts b/src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts index e9942be7c08..1f795310e9a 100644 --- a/src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts +++ b/src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts @@ -5,12 +5,22 @@ export const enum AgentSystemNotificationKind { WorktreeCreationFailure = 'worktreeCreationFailure', + /** Agent Merge started monitoring the session's branch. */ + AgentMergeEnabled = 'agentMergeEnabled', + /** Agent Merge stopped monitoring the session, usually on its own. */ + AgentMergeDisabled = 'agentMergeDisabled', } export const enum AgentSystemNotificationSeverity { Warning = 'warning', } +const knownKinds: ReadonlySet = new Set([ + AgentSystemNotificationKind.WorktreeCreationFailure, + AgentSystemNotificationKind.AgentMergeEnabled, + AgentSystemNotificationKind.AgentMergeDisabled, +]); + interface IHasSystemNotificationMeta { readonly _meta?: Record; } @@ -26,8 +36,9 @@ export function readAgentSystemNotificationMeta(source: IHasSystemNotificationMe if (!meta) { return {}; } + const kind = meta['kind']; return { - kind: meta['kind'] === AgentSystemNotificationKind.WorktreeCreationFailure ? meta['kind'] : undefined, + kind: typeof kind === 'string' && knownKinds.has(kind) ? kind as AgentSystemNotificationKind : undefined, severity: meta['severity'] === AgentSystemNotificationSeverity.Warning ? meta['severity'] : undefined, }; } diff --git a/src/vs/platform/agentHost/node/agentHostLocalTurns.ts b/src/vs/platform/agentHost/node/agentHostLocalTurns.ts index 0388960210b..ccfffb0622a 100644 --- a/src/vs/platform/agentHost/node/agentHostLocalTurns.ts +++ b/src/vs/platform/agentHost/node/agentHostLocalTurns.ts @@ -17,6 +17,11 @@ export interface IAgentHostLocalTurns { /** Whether `turnId` is a known host-injected local turn in `chat`. */ isLocal(chat: string, turnId: string): boolean; + /** + * Resolves the anchor a host-injected turn must be recorded against: the + * nearest preceding turn in `chat` that the agent SDK actually owns. + */ + findAnchorTurnId(chat: string, turns: readonly Turn[], turnId: string): string | undefined; /** Records `turn` as a host-injected local turn anchored to `anchorTurnId`. */ record(session: string, chat: string, turn: Turn, anchorTurnId: string | undefined): void; } @@ -93,6 +98,20 @@ export class AgentHostLocalTurns implements IAgentHostLocalTurns { }).finally(() => ref.dispose()); } + /** + * Resolves the anchor a host-injected turn must be recorded against: the + * nearest preceding turn in `chat` that the agent SDK actually owns, or + * `undefined` when the turn precedes every concrete turn. + */ + findAnchorTurnId(chat: string, turns: readonly Turn[], turnId: string): string | undefined { + for (let i = turns.findIndex(turn => turn.id === turnId) - 1; i >= 0; i--) { + if (!this.isLocal(chat, turns[i].id)) { + return turns[i].id; + } + } + return undefined; + } + /** * Loads persisted local turns for `session`, populating the in-memory index * (keyed by each record's chat), and returns the records for `chat` in diff --git a/src/vs/platform/agentHost/node/agentMergeController.ts b/src/vs/platform/agentHost/node/agentMergeController.ts index 0ca88c19e18..dc26fb9d1d3 100644 --- a/src/vs/platform/agentHost/node/agentMergeController.ts +++ b/src/vs/platform/agentHost/node/agentMergeController.ts @@ -15,9 +15,10 @@ import { IGitHubService } from '../../github/common/githubService.js'; import { PullRequestRef, PullRequestSnapshot, PullRequestSubscription } from '../../github/common/githubPullRequestService.js'; import { GitHubRequestError } from '../../github/common/githubTransport.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentMergeConfigKey, AgentMergeConfiguration, AgentMergeSessionState, AgentMergeTarget, agentMergeGateFragments, agentMergeRootConfigSchema, defaultAgentMergeConfiguration, evaluateAgentMerge, readAgentMergeSessionState, resolveAgentMergeConfiguration } from '../common/agentMerge.js'; +import { AgentMergeConfigKey, AgentMergeConfiguration, AgentMergeDisableReason, AgentMergeSessionState, AgentMergeTarget, agentMergeDisableReasons, agentMergeDisabledNotice, agentMergeEnabledNotice, agentMergeGateFragments, agentMergeRootConfigSchema, defaultAgentMergeConfiguration, evaluateAgentMerge, readAgentMergeSessionState, resolveAgentMergeConfiguration } from '../common/agentMerge.js'; import { buildAgentMergePrompt } from '../common/agentMergePrompt.js'; import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; +import { AgentSystemNotificationKind } from '../common/meta/agentSystemNotificationMeta.js'; import { deriveGitHubEndpoints } from '../common/githubEndpoints.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { ActionType } from '../common/state/protocol/common/actions.js'; @@ -40,6 +41,11 @@ const indeterminateObservationGap = 2 * backstopInterval; export interface IAgentMergeControllerOptions { readonly startTurn: (session: string, turnId: string, prompt: string) => boolean; readonly cancelTurn: (session: string, turnId: string) => void; + /** + * Posts an Agent Merge state change into the session transcript. The notice + * is client-visible only; it must never become part of the agent's context. + */ + readonly postNotice: (session: string, kind: AgentSystemNotificationKind, content: string) => void; readonly getAutonomousSessionConfig: (session: string, config: Readonly>) => Record | undefined; } @@ -89,6 +95,13 @@ export class AgentMergeController extends Disposable { /** Sessions kept resident so their monitoring survives with no client subscriber. */ private readonly _heldSessions = new Set(); + /** + * Sessions this controller is monitoring in the current host lifetime. Only a + * session in this set can produce the "turned off" notice, so the re-entrant + * sync that {@link _disable} triggers cannot post a second, reasonless one. + */ + private readonly _monitoredSessions = new Set(); + constructor( private readonly _options: IAgentMergeControllerOptions, @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, @@ -113,7 +126,10 @@ export class AgentMergeController extends Disposable { } void this._completeTurn(event.session); })); - this._register(this._stateManager.onDidRemoveSession(session => this._stopRuntime(session))); + this._register(this._stateManager.onDidRemoveSession(session => { + this._monitoredSessions.delete(session); + this._stopRuntime(session); + })); this._register(this._gitStateService.onDidRefreshSessionGitState(session => this._schedule(session, 0))); this._register(this._gitStateService.onDidChangeSessionGitHubState(session => this._schedule(session, 0))); this._register(this._configurationService.onDidRootConfigChange(() => { @@ -215,6 +231,13 @@ export class AgentMergeController extends Disposable { if (this._runtimes.has(session) || agentMerge?.injectedConfiguration) { this._logService.info(`[AgentMergeController] Stopping disabled session: session=${session}`); } + // A session still marked monitored reached this branch because + // something outside the controller — the user, or another client — + // turned Agent Merge off. Self-disables clear the mark first and + // report their own reason. + if (this._monitoredSessions.delete(session) && state) { + this._postNotice(session, AgentSystemNotificationKind.AgentMergeDisabled, agentMergeDisabledNotice()); + } if (agentMerge?.injectedConfiguration) { this._restoreInjectedConfiguration(session, agentMerge); } @@ -222,7 +245,7 @@ export class AgentMergeController extends Disposable { return; } if (isSessionStatusArchived(state.status)) { - this._disable(session, agentMerge, 'the session was archived'); + this._disable(session, agentMerge, agentMergeDisableReasons.sessionArchived()); return; } if (!this._isFeatureEnabled()) { @@ -251,6 +274,7 @@ export class AgentMergeController extends Disposable { if (!runtime) { runtime = new AgentMergeRuntime(session, () => this._queueEvaluation(session)); this._runtimes.set(session, runtime); + this._monitoredSessions.add(session); this._logService.info(`[AgentMergeController] Started session runtime: session=${session}, hasTarget=${agentMerge.target !== undefined}, overrides=${formatOverrideKeys(agentMerge)}`); } this._schedule(session, 0); @@ -364,11 +388,14 @@ export class AgentMergeController extends Disposable { const now = new Date().toISOString(); target = { branchName, enabledAt: now, commentWatermark: now }; this._logService.info(`[AgentMergeController] Captured session branch and feedback watermark: session=${session}`); + // Announce only on the first capture: a resumed session already has a + // target, so restarting the host must not repeat the notice. + this._postNotice(session, AgentSystemNotificationKind.AgentMergeEnabled, agentMergeEnabledNotice(branchName)); this._updateAgentMergeState(session, agentMerge, { target }); return; } if (target.branchName !== branchName) { - this._disable(session, agentMerge, `branch changed from ${target.branchName} to ${branchName}`); + this._disable(session, agentMerge, agentMergeDisableReasons.branchChanged(target.branchName, branchName)); return; } @@ -378,7 +405,7 @@ export class AgentMergeController extends Disposable { } const refreshedState = this._stateManager.getSessionState(session); if (!this._hasTargetBranch(refreshedState, target.branchName)) { - this._disable(session, agentMerge, 'the checked-out branch changed while pull request state was refreshing'); + this._disable(session, agentMerge, agentMergeDisableReasons.branchChangedWhileRefreshing()); return; } const gitHubState = readSessionGitHubState(refreshedState?._meta); @@ -395,13 +422,13 @@ export class AgentMergeController extends Disposable { return; } if (pullRequestUrl && pullRequestUrl.toLowerCase() !== target.pullRequestUrl.toLowerCase()) { - this._disable(session, agentMerge, 'the session became associated with a different pull request'); + this._disable(session, agentMerge, agentMergeDisableReasons.differentPullRequest()); return; } const parsed = parsePullRequestUrl(target.pullRequestUrl); if (!parsed) { - this._disable(session, agentMerge, 'the associated pull request URL is invalid'); + this._disable(session, agentMerge, agentMergeDisableReasons.invalidPullRequestUrl()); return; } const ref = await this._resolveRef(parsed, runtime.abortController.signal); @@ -409,7 +436,7 @@ export class AgentMergeController extends Disposable { return; } if (!ref) { - this._disable(session, agentMerge, 'the bound pull request belongs to a different GitHub host than the signed-in account'); + this._disable(session, agentMerge, agentMergeDisableReasons.differentGitHubHost()); return; } const subscription = await this._ensureSubscription(session, runtime, ref); @@ -428,13 +455,13 @@ export class AgentMergeController extends Disposable { case 'indeterminate': this._reportBlockedCredential(session, runtime, snapshot); if (this._isIndeterminateBudgetExhausted(session, runtime, gate.cause)) { - this._disable(session, agentMerge, `the pull request state could not be evaluated for ${Math.round(maximumIndeterminateDuration / 60_000)} minutes: ${gate.reason}`); + this._disable(session, agentMerge, agentMergeDisableReasons.indeterminate(Math.round(maximumIndeterminateDuration / 60_000), gate.reason)); return; } runtime.backstopScheduler.schedule(); return; case 'terminal': - this._disable(session, agentMerge, 'the pull request is closed or merged'); + this._disable(session, agentMerge, agentMergeDisableReasons.pullRequestClosed()); return; case 'noWork': runtime.backstopScheduler.schedule(); @@ -454,7 +481,7 @@ export class AgentMergeController extends Disposable { const totalPromptCount = (agentMerge.totalPromptCount ?? 0) + 1; if (repeatedPromptCount >= maximumRepeatedPromptCount || totalPromptCount > maximumTotalPromptCount) { this._logService.warn(`[AgentMergeController] Repair attempt budget exhausted: session=${session}, repeatedAttempts=${repeatedPromptCount}, totalAttempts=${totalPromptCount}`); - this._disable(session, agentMerge, 'the same pull request blockers remained after repeated repair attempts'); + this._disable(session, agentMerge, agentMergeDisableReasons.repairBudgetExhausted()); return; } const turnId = generateUuid(); @@ -698,7 +725,7 @@ export class AgentMergeController extends Disposable { } const result = await this._gitHubService.mutations.merge(preparation, { method, authorization }, runtime.abortController.signal); this._logService.info(`[AgentMergeController] Pull request merged natively: session=${session}, method=${method}, outcome=${result.outcome}`); - this._disable(session, currentState, 'the pull request was merged'); + this._disable(session, currentState, agentMergeDisableReasons.pullRequestMerged()); } private async _completeTurn(session: string): Promise { @@ -737,9 +764,13 @@ export class AgentMergeController extends Disposable { }); } - private _disable(session: string, current: AgentMergeSessionState, reason: string): void { - this._logService.info(`[AgentMergeController] Disabling Agent Merge for ${session}: ${reason}`); + private _disable(session: string, current: AgentMergeSessionState, reason: AgentMergeDisableReason): void { + this._logService.info(`[AgentMergeController] Disabling Agent Merge for ${session}: ${reason.log}`); this._activeTurns.delete(session); + // Claim the transition before the config write re-enters `_doSyncSession`, + // so the reasoned notice below is the only one the user sees. + this._monitoredSessions.delete(session); + this._postNotice(session, AgentSystemNotificationKind.AgentMergeDisabled, reason.notice); const patch: Record = { [SessionConfigKey.AgentMerge]: { enabled: false, @@ -752,6 +783,18 @@ export class AgentMergeController extends Disposable { this._stopRuntime(session); } + /** + * Reports an Agent Merge state change in the session transcript. A failure to + * announce must never interrupt monitoring, so the notice is best-effort. + */ + private _postNotice(session: string, kind: AgentSystemNotificationKind, content: string): void { + try { + this._options.postNotice(session, kind, content); + } catch (error) { + this._logService.warn(`[AgentMergeController] Failed to post an Agent Merge notice: session=${session}`, error); + } + } + private _addInjectedConfigurationRestore(patch: Record, session: string, agentMerge: AgentMergeSessionState): void { const injected = agentMerge.injectedConfiguration; if (!injected) { diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 79c4e0416a3..c128778ea95 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -35,7 +35,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type Annotation, type AnnotationEntry, type AnnotationOrigin, type AnnotationsState, type ChatOrigin, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment, type TextRange } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js'; -import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; +import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withMessageHiddenFromTranscript, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../common/meta/agentSnapshotAttachmentMeta.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js'; @@ -80,6 +80,7 @@ import { AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext, type import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; import { AgentMergeController, type IAgentMergeControllerOptions } from './agentMergeController.js'; import { AgentMergeConfigKey, agentMergeRootConfigSchema, readAgentMergeSessionState } from '../common/agentMerge.js'; +import { AgentSystemNotificationKind, toAgentSystemNotificationMeta } from '../common/meta/agentSystemNotificationMeta.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; @@ -362,6 +363,7 @@ export interface IAgentServiceCallbacks { readonly canEvictChangeset: (changeset: string) => boolean; readonly startAgentMergeTurn: IAgentMergeControllerOptions['startTurn']; readonly cancelAgentMergeTurn: IAgentMergeControllerOptions['cancelTurn']; + readonly postAgentMergeNotice: IAgentMergeControllerOptions['postNotice']; readonly getAutonomousSessionConfig: IAgentMergeControllerOptions['getAutonomousSessionConfig']; readonly getAgent: IAgentSideEffectsOptions['getAgent']; readonly resolveWorkingDirectoryBeforeSend: NonNullable; @@ -636,6 +638,7 @@ export class AgentService extends Disposable implements IAgentService { canEvictChangeset: changeset => this._canEvictChangeset(changeset), startAgentMergeTurn: (session, turnId, prompt) => this._startAgentMergePrompt(session, turnId, prompt), cancelAgentMergeTurn: (session, turnId) => this._cancelAgentMergePrompt(session, turnId), + postAgentMergeNotice: (session, kind, content) => this._postAgentMergeNotice(session, kind, content), getAutonomousSessionConfig: (session, config) => this._findProviderForSession(session)?.getAutonomousSessionConfig?.(config), getAgent: session => this._findProviderForSession(session), resolveWorkingDirectoryBeforeSend: params => this._resolveWorkingDirectoryBeforeSend(params), @@ -658,6 +661,14 @@ export class AgentService extends Disposable implements IAgentService { } })); this._register(this._stateManager.onDidEmitNotification(e => this._onDidNotification.fire(e))); + // A notice raised mid-turn waits for the agent to finish so it can own a + // turn of its own and survive restore. + this._register(this._stateManager.onDidChangeSessionActiveTurn(({ session, active }) => { + if (!active) { + this._flushAgentMergeNotices(session); + } + })); + this._register(this._stateManager.onDidRemoveSession(session => this._pendingAgentMergeNotices.delete(session))); this._register(this._stateManager.onDidChangeSessionSummary(({ session, changes }) => { const meta = this._stateManager.getSessionSummary(session)?._meta; if (changes.modifiedAt !== undefined @@ -1180,6 +1191,76 @@ export class AgentService extends Disposable implements IAgentService { return true; } + /** + * Reports an Agent Merge state change in the session's default chat. + * + * The notice is dispatched as server state only — `AgentSideEffects` is + * deliberately not involved — so it reaches clients without ever being sent + * to the provider. It needs a turn of its own to live on, because the chat + * reducer drops response parts that no active turn claims; that turn's + * message is hidden so only the notice is rendered, and it is recorded as a + * local turn because the SDK transcript replayed on restore has never seen + * it. + * + * A notice raised while the agent holds a turn has to wait: starting a turn + * now would displace the running one, and appending to it would leave the + * notice on a turn the provider owns, so restore would replay that turn + * without it. + */ + private _postAgentMergeNotice(session: string, kind: AgentSystemNotificationKind, content: string): void { + if (this._stateManager.hasActiveTurn(session)) { + const pending = this._pendingAgentMergeNotices.get(session); + if (pending) { + pending.push({ kind, content }); + } else { + this._pendingAgentMergeNotices.set(session, [{ kind, content }]); + } + this._logService.debug(`[AgentService] Deferring an Agent Merge notice until the session is idle: session=${session}`); + return; + } + this._writeAgentMergeNotice(session, kind, content); + } + + /** Emits the notices that were waiting for a session's turn to end. */ + private _flushAgentMergeNotices(session: string): void { + const pending = this._pendingAgentMergeNotices.get(session); + if (!pending) { + return; + } + this._pendingAgentMergeNotices.delete(session); + for (const { kind, content } of pending) { + this._writeAgentMergeNotice(session, kind, content); + } + } + + /** Writes one Agent Merge notice as a completed, host-owned local turn. */ + private _writeAgentMergeNotice(session: string, kind: AgentSystemNotificationKind, content: string): void { + const chat = buildDefaultChatUri(session); + const channel = chat.toString(); + const turnId = generateUuid(); + this._stateManager.dispatchServerAction(channel, { + type: ActionType.ChatTurnStarted, + turnId, + startedAt: new Date().toISOString(), + message: withMessageHiddenFromTranscript({ text: content, origin: { kind: MessageKind.SystemNotification } }, true), + }); + this._stateManager.dispatchServerAction(channel, { + type: ActionType.ChatResponsePart, + turnId, + part: { + kind: ResponsePartKind.SystemNotification, + content, + _meta: toAgentSystemNotificationMeta({ kind }), + }, + }); + this._stateManager.dispatchServerAction(channel, { type: ActionType.ChatTurnComplete, turnId, duration: 0 }); + const turns = this._stateManager.getSessionState(chat)?.turns; + const recorded = turns?.find(turn => turn.id === turnId); + if (turns && recorded) { + this._localTurns.record(session, channel, recorded, this._localTurns.findAnchorTurnId(channel, turns, turnId)); + } + } + /** * Cancels a repair turn this host started for Agent Merge, so a stopped or * revoked controller cannot leave an autonomous turn running. @@ -1328,6 +1409,8 @@ export class AgentService extends Disposable implements IAgentService { private _agentMergeRestore: Promise = Promise.resolve(); private _agentMergeIndexWrites: Promise = Promise.resolve(); + /** Agent Merge notices waiting for a session's in-flight turn to finish. */ + private readonly _pendingAgentMergeNotices = new Map(); /** Test surface: settles once the startup Agent Merge restore pass and the index writes it enqueued have run. */ async whenAgentMergeSessionsRestored(): Promise { diff --git a/src/vs/platform/agentHost/node/agentServiceComposition.ts b/src/vs/platform/agentHost/node/agentServiceComposition.ts index 182e505a828..9d9c6772903 100644 --- a/src/vs/platform/agentHost/node/agentServiceComposition.ts +++ b/src/vs/platform/agentHost/node/agentServiceComposition.ts @@ -99,6 +99,7 @@ export function createAgentServiceComposition( const agentMergeController = owned.add(instantiationService.createInstance(AgentMergeController, { startTurn: (session, turnId, prompt) => callbackAdapter.value.startAgentMergeTurn(session, turnId, prompt), cancelTurn: (session, turnId) => callbackAdapter.value.cancelAgentMergeTurn(session, turnId), + postNotice: (session, kind, content) => callbackAdapter.value.postAgentMergeNotice(session, kind, content), getAutonomousSessionConfig: (session, config) => callbackAdapter.value.getAutonomousSessionConfig(session, config), })); // Resolve this even before first use so its session-data deletion listener diff --git a/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts b/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts index f1859ed3ff7..02bb256d26c 100644 --- a/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts +++ b/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts @@ -217,15 +217,7 @@ export class AgentHostLocalCommands extends Disposable { if (index < 0) { return; } - // Anchor = the nearest preceding turn in this chat that is not itself a - // local turn. - let anchorTurnId: string | undefined; - for (let i = index - 1; i >= 0; i--) { - if (!this._localTurns.isLocal(chat, turns[i].id)) { - anchorTurnId = turns[i].id; - break; - } - } + const anchorTurnId = this._localTurns.findAnchorTurnId(chat, turns, turnId); this._localTurns.record(session, chat, sanitizeLocalTurnForPersistence(turns[index]), anchorTurnId); } } diff --git a/src/vs/platform/agentHost/test/node/agentMergeController.test.ts b/src/vs/platform/agentHost/test/node/agentMergeController.test.ts index acb92d3d0e8..9a1c3436e0a 100644 --- a/src/vs/platform/agentHost/test/node/agentMergeController.test.ts +++ b/src/vs/platform/agentHost/test/node/agentMergeController.test.ts @@ -12,6 +12,7 @@ import { mock } from '../../../../base/test/common/mock.js'; import { AgentMergeConfigKey, agentMergeRootConfigSchema, readAgentMergeSessionState } from '../../common/agentMerge.js'; import { AgentHostAutoApprovePolicyRestrictedConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; import { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; +import { AgentSystemNotificationKind } from '../../common/meta/agentSystemNotificationMeta.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; import { SessionStatus, buildDefaultChatUri, MessageKind, withSessionGitState, type SessionSummary } from '../../common/state/sessionState.js'; @@ -43,6 +44,7 @@ suite('AgentMergeController', () => { { startTurn: () => false, cancelTurn: () => { }, + postNotice: () => { }, getAutonomousSessionConfig: () => ({ [SessionConfigKey.Mode]: 'autopilot', [SessionConfigKey.AutoApprove]: 'assisted', @@ -243,6 +245,7 @@ suite('AgentMergeController', () => { { startTurn: () => false, cancelTurn: () => { }, + postNotice: () => { }, getAutonomousSessionConfig: () => ({}), }, stateManager, @@ -307,6 +310,7 @@ suite('AgentMergeController', () => { { startTurn: () => false, cancelTurn: () => { }, + postNotice: () => { }, getAutonomousSessionConfig: () => ({}), }, stateManager, @@ -354,6 +358,7 @@ suite('AgentMergeController', () => { readonly stateManager: AgentHostStateManager; readonly configurationService: AgentConfigurationService; readonly session: string; + readonly notices: { readonly kind: AgentSystemNotificationKind; readonly content: string }[]; } { const logService = new NullLogService(); const stateManager = disposables.add(new AgentHostStateManager(logService)); @@ -364,10 +369,12 @@ suite('AgentMergeController', () => { override readonly onDidChangeSessionGitHubState = Event.None; }(); const endpointService = disposables.add(new AgentHostGitHubEndpointService(configurationService, logService)); + const notices: { kind: AgentSystemNotificationKind; content: string }[] = []; disposables.add(new AgentMergeController( { startTurn: () => false, cancelTurn: () => { }, + postNotice: (_session, kind, content) => notices.push({ kind, content }), getAutonomousSessionConfig: () => configurationService.getRootValue(platformRootSchema, AgentHostAutoApprovePolicyRestrictedConfigKey) === true ? { [SessionConfigKey.Mode]: 'autopilot' } : { @@ -388,9 +395,96 @@ suite('AgentMergeController', () => { schema: platformSessionSchema.toProtocol(), values: {}, }); - return { stateManager, configurationService, session }; + return { stateManager, configurationService, session, notices }; } + test('announces enablement once it captures a branch, and again on the branch that turned it off', async () => { + const logService = new NullLogService(); + const stateManager = disposables.add(new AgentHostStateManager(logService)); + const configurationService = disposables.add(new AgentConfigurationService(stateManager, logService)); + configurationService.updateRootConfig({ [AgentMergeConfigKey.Enabled]: true }); + const session = `copilot:/agent-merge-controller-${++sessionCounter}`; + const gitStateService = new class extends mock() { + override readonly onDidRefreshSessionGitState = Event.None; + override readonly onDidChangeSessionGitHubState = Event.None; + override async attachSessionGitHubPullRequest(): Promise { } + }(); + const endpointService = disposables.add(new AgentHostGitHubEndpointService(configurationService, logService)); + const notices: { kind: AgentSystemNotificationKind; content: string }[] = []; + disposables.add(new AgentMergeController( + { + startTurn: () => false, + cancelTurn: () => { }, + postNotice: (_session, kind, content) => notices.push({ kind, content }), + getAutonomousSessionConfig: () => ({}), + }, + stateManager, + configurationService, + gitStateService, + new class extends mock() { }(), + endpointService, + logService, + )); + stateManager.createSession(summary(session)); + stateManager.setSessionConfig(session, { + schema: platformSessionSchema.toProtocol(), + values: {}, + }); + stateManager.setSessionMeta(session, withSessionGitState(undefined, { branchName: 'feature', baseBranchName: 'main' })); + const captured = new Promise(resolve => { + disposables.add(stateManager.onDidChangeSessionConfig(event => { + if (event.session.toString() === session && readAgentMergeSessionState(event.current?.values)?.target) { + resolve(); + } + })); + }); + configurationService.updateSessionConfig(session, { [SessionConfigKey.AgentMerge]: { enabled: true } }); + stateManager.dispatchServerAction(session, { type: ActionType.SessionReady }); + await captured; + const afterEnable = [...notices]; + + // The checkout moves to an unrelated branch, which is what silently + // stopped Agent Merge before it explained itself. + stateManager.setSessionMeta(session, withSessionGitState(undefined, { branchName: 'main', baseBranchName: 'main' })); + await timeout(0); + await timeout(0); + + assert.deepStrictEqual({ + afterEnable, + notices, + enabled: readAgentMergeSessionState(configurationService.getSessionConfigValues(session))?.enabled, + }, { + afterEnable: [{ kind: AgentSystemNotificationKind.AgentMergeEnabled, content: 'Agent Merge is on and watching `feature`.' }], + notices: [ + { kind: AgentSystemNotificationKind.AgentMergeEnabled, content: 'Agent Merge is on and watching `feature`.' }, + { kind: AgentSystemNotificationKind.AgentMergeDisabled, content: 'Agent Merge was turned off because the checked-out branch changed from `feature` to `main`.' }, + ], + enabled: false, + }); + }); + + test('reports a self-disable once, and reports a user disable separately', () => { + const { stateManager, configurationService, session, notices } = createControllerHarness(disposables); + configurationService.updateSessionConfig(session, { [SessionConfigKey.AgentMerge]: { enabled: true } }); + stateManager.dispatchServerAction(session, { type: ActionType.SessionReady }); + // Archiving disables from inside the controller; the re-entrant sync its + // own config write triggers must not add a second, reasonless notice. + stateManager.dispatchServerAction(session, { type: ActionType.SessionIsArchivedChanged, isArchived: true }); + const afterSelfDisable = [...notices]; + + stateManager.dispatchServerAction(session, { type: ActionType.SessionIsArchivedChanged, isArchived: false }); + configurationService.updateSessionConfig(session, { [SessionConfigKey.AgentMerge]: { enabled: true } }); + configurationService.updateSessionConfig(session, { [SessionConfigKey.AgentMerge]: { enabled: false } }); + + assert.deepStrictEqual({ afterSelfDisable, notices }, { + afterSelfDisable: [{ kind: AgentSystemNotificationKind.AgentMergeDisabled, content: 'Agent Merge was turned off because this session was archived.' }], + notices: [ + { kind: AgentSystemNotificationKind.AgentMergeDisabled, content: 'Agent Merge was turned off because this session was archived.' }, + { kind: AgentSystemNotificationKind.AgentMergeDisabled, content: 'Agent Merge was turned off for this session.' }, + ], + }); + }); + test('resolves the API host a credential must match for every GitHub deployment', () => { assert.deepStrictEqual({ dotCom: parsePullRequestUrl('https://github.com/octo/repo/pull/1')?.apiHost, diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 07aca652873..26b0db1e8a2 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -41,7 +41,7 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentMergeConfigKey, readAgentMergeSessionState } from '../../common/agentMerge.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope, NotificationType, type INotification } from '../../common/state/sessionActions.js'; -import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isDefaultChatUri, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionCreationReference, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; +import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isDefaultChatUri, isMessageHiddenFromTranscript, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionCreationReference, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; import { ChatInteractivity, type MessageAttachment } from '../../common/state/protocol/state.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { readAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; @@ -13713,6 +13713,45 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual(await orchestratorDb.listAgentMergeEnabledSessions(), [sessionResource.toString()]); }); + test('a disable explains itself in the transcript without telling the agent', async () => { + const orchestratorDb = new TestAgentHostOrchestratorDatabase(); + const sessionDb = new TestSessionDatabase(); + const { localService, localAgent, sessionResource } = await createEnabledSession(sessionDb, orchestratorDb); + const sessionStr = sessionResource.toString(); + const chat = buildDefaultChatUri(sessionStr); + + getConfigurationService(localService).updateSessionConfig(sessionStr, { [SessionConfigKey.AgentMerge]: { enabled: false } }); + await timeout(0); + + const turns = getStateManager(localService).getSessionState(chat)?.turns ?? []; + const notice = turns[turns.length - 1]; + assert.deepStrictEqual({ + // The turn exists only to carry the notice, so its own message + // stays out of the transcript. + hiddenMessage: isMessageHiddenFromTranscript(notice.message), + origin: notice.message.origin.kind, + state: notice.state, + responseParts: notice.responseParts, + // The whole point of a server-only dispatch: the agent's context + // must not gain host bookkeeping. + sentToAgent: localAgent.sendMessageCalls.length, + // The SDK transcript replayed on restore has never seen this turn, + // so it only survives reload as a local turn. + persistedLocally: (await sessionDb.getLocalTurns()).map(record => ({ chatUri: record.chatUri, turnId: record.turnId })), + }, { + hiddenMessage: true, + origin: MessageKind.SystemNotification, + state: TurnState.Complete, + responseParts: [{ + kind: ResponsePartKind.SystemNotification, + content: 'Agent Merge was turned off for this session.', + _meta: { kind: 'agentMergeDisabled' }, + }], + sentToAgent: 0, + persistedLocally: [{ chatUri: chat.toString(), turnId: notice.id }], + }); + }); + test('a persisted Agent-Merge-enabled session begins monitoring on a fresh host and becomes MRU-eligible once disabled', () => { return runWithFakedTimers({ useFakeTimers: true }, async () => { const orchestratorDb = new TestAgentHostOrchestratorDatabase(); @@ -13748,6 +13787,55 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('a notice raised mid-turn waits for the agent to finish so it survives restore', async () => { + const orchestratorDb = new TestAgentHostOrchestratorDatabase(); + const sessionDb = new TestSessionDatabase(); + const { localService, sessionResource } = await createEnabledSession(sessionDb, orchestratorDb); + const sessionStr = sessionResource.toString(); + const chat = buildDefaultChatUri(sessionStr); + const stateManager = getStateManager(localService); + const turnsOf = () => stateManager.getSessionState(chat)?.turns ?? []; + + stateManager.dispatchServerAction(chat.toString(), { + type: ActionType.ChatTurnStarted, + turnId: 'agent-turn', + startedAt: new Date().toISOString(), + message: { text: 'do the thing', origin: { kind: MessageKind.User } }, + }); + getConfigurationService(localService).updateSessionConfig(sessionStr, { [SessionConfigKey.AgentMerge]: { enabled: false } }); + await timeout(0); + const duringTurn = { + // The running turn must keep its own response stream: a notice + // appended here would ride on a turn the provider owns. + activeTurnParts: stateManager.getChatState(chat)?.activeTurn?.responseParts.length, + persisted: (await sessionDb.getLocalTurns()).length, + }; + + stateManager.dispatchServerAction(chat.toString(), { type: ActionType.ChatTurnComplete, turnId: 'agent-turn', duration: 1 }); + await timeout(0); + + const notice = turnsOf()[turnsOf().length - 1]; + assert.deepStrictEqual({ + duringTurn, + afterTurn: { + responseParts: notice.responseParts, + anchoredTo: (await sessionDb.getLocalTurns()).map(record => record.anchorTurnId), + persistedTurnIds: (await sessionDb.getLocalTurns()).map(record => record.turnId), + }, + }, { + duringTurn: { activeTurnParts: 0, persisted: 0 }, + afterTurn: { + responseParts: [{ + kind: ResponsePartKind.SystemNotification, + content: 'Agent Merge was turned off for this session.', + _meta: { kind: 'agentMergeDisabled' }, + }], + anchoredTo: ['agent-turn'], + persistedTurnIds: [notice.id], + }, + }); + }); + test('an archived session is dropped from the index instead of being restored', async () => { const orchestratorDb = new TestAgentHostOrchestratorDatabase(); const sessionDb = new TestSessionDatabase(); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 8616537339b..aef51709cad 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -477,9 +477,20 @@ export function systemNotificationToChatPart(content: StringOrMarkdown | undefin const value = stringOrMarkdownToString(content, connectionAuthority); const markdown = typeof value === 'string' ? new MarkdownString(value) : value; const meta = readAgentSystemNotificationMeta({ _meta }); - return meta.kind === AgentSystemNotificationKind.WorktreeCreationFailure && meta.severity === AgentSystemNotificationSeverity.Warning - ? { kind: 'warning', content: markdown } - : { kind: 'systemNotification', content: markdown }; + switch (meta.kind) { + case AgentSystemNotificationKind.WorktreeCreationFailure: + return meta.severity === AgentSystemNotificationSeverity.Warning + ? { kind: 'warning', content: markdown } + : { kind: 'systemNotification', content: markdown }; + // Agent Merge reports a state change rather than a completed step, so the + // default check would misdescribe both of these. + case AgentSystemNotificationKind.AgentMergeEnabled: + return { kind: 'systemNotification', content: markdown, icon: Codicon.gitMerge }; + case AgentSystemNotificationKind.AgentMergeDisabled: + return { kind: 'systemNotification', content: markdown, icon: Codicon.circleSlash }; + default: + return { kind: 'systemNotification', content: markdown }; + } } /** diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSystemNotificationContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSystemNotificationContentPart.ts index 92f4e85b25d..6c7967e0199 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSystemNotificationContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSystemNotificationContentPart.ts @@ -5,6 +5,7 @@ import { Codicon } from '../../../../../../base/common/codicons.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; import { IChatSystemNotificationPart } from '../../../common/chatService/chatService.js'; @@ -23,10 +24,12 @@ export class ChatSystemNotificationContentPart extends Disposable implements ICh super(); const rendered = this._register(renderer.render(notification.content)); - this.domNode = this._register(instantiationService.createInstance(ChatProgressSubPart, rendered.element, Codicon.check, undefined)).domNode; + this.domNode = this._register(instantiationService.createInstance(ChatProgressSubPart, rendered.element, notification.icon ?? Codicon.check, undefined)).domNode; } hasSameContent(other: IChatRendererContent): boolean { - return other.kind === 'systemNotification' && other.content.value === this.notification.content.value; + return other.kind === 'systemNotification' + && other.content.value === this.notification.content.value + && ThemeIcon.isEqual(other.icon ?? Codicon.check, this.notification.icon ?? Codicon.check); } } 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 d069fdc6a85..8bb557470f9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -3351,7 +3351,9 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-item-container .progress-container { display: flex; - align-items: center; + /* Keep the icon on the first line: a wrapped message must not drag it down + to the middle of the block. */ + align-items: flex-start; gap: 4px; margin: 0 0 var(--vscode-spacing-size160) 0; font-size: var(--vscode-fontSize-body1); @@ -3362,6 +3364,9 @@ have to be updated for changes to the rules above, or to support more deeply nes > .codicon[class*='codicon-'] { font-size: var(--vscode-codiconFontSize-compact); + /* Makes the glyph box exactly one text line tall, so it stays optically + centred on the first line at any font size. */ + line-height: inherit; &::before { font-size: var(--vscode-codiconFontSize-compact); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 5723e16b16e..d7ef160115f 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -300,6 +300,11 @@ export interface IChatProgressMessage { export interface IChatSystemNotificationPart { content: IMarkdownString; kind: 'systemNotification'; + /** + * Icon shown beside the notification. Defaults to a check, which only suits + * notifications that report something completing. + */ + icon?: ThemeIcon; } export interface IChatTask extends IChatTaskDto { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 2fff026eb20..75d969f3eb8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -2383,6 +2383,29 @@ suite('stateToProgressAdapter', () => { }); }); + test('gives each Agent Merge notice an icon that matches what it reports', () => { + const notice = (kind: AgentSystemNotificationKind) => activeTurnToProgress(URI.file('/'), createActiveTurnState([{ + kind: ResponsePartKind.SystemNotification, + content: 'Agent Merge changed state', + _meta: toAgentSystemNotificationMeta({ kind }), + }]), undefined)[0]; + + assert.deepStrictEqual({ + enabled: notice(AgentSystemNotificationKind.AgentMergeEnabled), + disabled: notice(AgentSystemNotificationKind.AgentMergeDisabled), + // An unrecognized kind must still render, using the default check. + unknown: activeTurnToProgress(URI.file('/'), createActiveTurnState([{ + kind: ResponsePartKind.SystemNotification, + content: 'Agent Merge changed state', + _meta: { kind: 'somethingNewer' }, + }]), undefined)[0], + }, { + enabled: { kind: 'systemNotification', content: new MarkdownString('Agent Merge changed state'), icon: Codicon.gitMerge }, + disabled: { kind: 'systemNotification', content: new MarkdownString('Agent Merge changed state'), icon: Codicon.circleSlash }, + unknown: { kind: 'systemNotification', content: new MarkdownString('Agent Merge changed state') }, + }); + }); + test('produces thinking progress for reasoning', () => { const result = activeTurnToProgress(URI.file('/'), createActiveTurnState([ { kind: ResponsePartKind.Reasoning, id: 'r-1', content: 'Let me think about this...' }, diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeNotice.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeNotice.fixture.ts new file mode 100644 index 00000000000..714c5161665 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeNotice.fixture.ts @@ -0,0 +1,121 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { agentMergeDisableReasons, agentMergeDisabledNotice, agentMergeEnabledNotice } from '../../../../../platform/agentHost/common/agentMerge.js'; +import { AgentSystemNotificationKind, toAgentSystemNotificationMeta } from '../../../../../platform/agentHost/common/meta/agentSystemNotificationMeta.js'; +import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; +import { systemNotificationToChatPart } from '../../../../contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.js'; +import { ChatContentMarkdownRenderer } from '../../../../contrib/chat/browser/widget/chatContentMarkdownRenderer.js'; +import { ChatSystemNotificationContentPart } from '../../../../contrib/chat/browser/widget/chatContentParts/chatSystemNotificationContentPart.js'; +import { IChatMarkdownAnchorService } from '../../../../contrib/chat/browser/widget/chatContentParts/chatMarkdownAnchorService.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; + +import '../../../../contrib/chat/browser/widget/media/chat.css'; + +/** + * Renders the notices the Agent Merge controller posts into a session + * transcript when it starts or stops monitoring a pull request. + * + * Each fixture drives the real host payload through + * {@link systemNotificationToChatPart}, so the rendered icon and content come + * from the same mapping the Agents window uses rather than from hand-built + * view data that could drift from it. + */ +function renderNotice(context: ComponentFixtureContext, content: string, kind: AgentSystemNotificationKind): void { + const { container, disposableStore } = context; + + const anchorService = new class extends mock() { + override register() { return { dispose() { } }; } + }(); + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: context.theme, + additionalServices: (reg) => { + reg.define(IMarkdownRendererService, MarkdownRendererService); + reg.defineInstance(IChatMarkdownAnchorService, anchorService); + }, + }); + + const progress = systemNotificationToChatPart(content, 'fixture', toAgentSystemNotificationMeta({ kind })); + if (progress?.kind !== 'systemNotification') { + throw new Error(`Expected a system notification, got '${progress?.kind}'`); + } + + const markdownRenderer = instantiationService.createInstance(ChatContentMarkdownRenderer); + const part = disposableStore.add(instantiationService.createInstance(ChatSystemNotificationContentPart, progress, markdownRenderer)); + + // `.interactive-session` supplies the chat font tokens and + // `.interactive-item-container` the row layout the progress container needs. + container.style.width = '400px'; + container.style.padding = '8px'; + container.classList.add('interactive-session'); + const itemContainer = dom.$('.interactive-item-container'); + itemContainer.appendChild(part.domNode); + container.appendChild(itemContainer); +} + +export default defineThemedFixtureGroup({ path: 'chat/' }, { + Enabled: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderNotice( + ctx, + agentMergeEnabledNotice('benibenj/agents/hover-widget-structure-improvements'), + AgentSystemNotificationKind.AgentMergeEnabled, + ), + }), + + DisabledByUser: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderNotice( + ctx, + agentMergeDisabledNotice(), + AgentSystemNotificationKind.AgentMergeDisabled, + ), + }), + + /** The silent self-disable that made a monitored session look broken. */ + DisabledByBranchChange: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderNotice( + ctx, + agentMergeDisableReasons.branchChanged('benibenj/agent-merge-widget', 'main').notice, + AgentSystemNotificationKind.AgentMergeDisabled, + ), + }), + + DisabledByMerge: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderNotice( + ctx, + agentMergeDisableReasons.pullRequestMerged().notice, + AgentSystemNotificationKind.AgentMergeDisabled, + ), + }), + + /** The longest reason, so wrapping keeps the icon aligned to the first line. */ + DisabledByRepairBudget: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderNotice( + ctx, + agentMergeDisableReasons.repairBudgetExhausted().notice, + AgentSystemNotificationKind.AgentMergeDisabled, + ), + }), + + /** + * A reason long enough to wrap onto three lines, pinning the icon to the + * first line rather than the middle of the block. + */ + DisabledByIndeterminateState: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderNotice( + ctx, + agentMergeDisableReasons.indeterminate(30, 'checks could not be read').notice, + AgentSystemNotificationKind.AgentMergeDisabled, + ), + }), +}); From cc04d10042a0e0e66b00fbcd06d3beefac6a1af7 Mon Sep 17 00:00:00 2001 From: roblourens Date: Tue, 25 Aug 2026 15:16:35 -0700 Subject: [PATCH 016/116] Agent Host: Make debug log export best effort (#332581) Continue exporting locally available logs when remote session state, collection, or archive transfer fails. Collect known VS Code log files directly from disk instead of materializing output channel models. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../actions/exportAgentHostDebugLogsAction.ts | 203 ++++++++++-------- .../exportAgentHostDebugLogsService.ts | 47 ++-- .../browser/exportAgentHostDebugLogs.test.ts | 35 ++- 3 files changed, 176 insertions(+), 109 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts index 457dcde2160..c6e41f38e0d 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts @@ -15,29 +15,25 @@ import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/ import { AGENT_HOST_ENABLED_CONTEXT_KEY } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { IAgentHostService, type AgentHostDebugLogsArtifactKind, type IAgentConnection, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../../../../../platform/agentHost/common/agentService.js'; import { IRemoteAgentHostService, remoteAgentHostLogOutputChannelId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; -import { DEFAULT_CHAT_ID, getSessionChatResource, StateComponents } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { DEFAULT_CHAT_ID, getSessionChatResource, StateComponents, type SessionState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IsWebContext } from '../../../../../platform/contextkey/common/contextkeys.js'; import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; -import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { ByteSize, IFileService } from '../../../../../platform/files/common/files.js'; -import { createDecorator, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { createDecorator, IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; import { IProgressService, ProgressLocation } from '../../../../../platform/progress/common/progress.js'; -import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; -import { IOutputService, isMultiSourceOutputChannelDescriptor, isSingleSourceOutputChannelDescriptor } from '../../../../services/output/common/output.js'; +import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; import { IChatWidgetService } from '../chat.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { COPILOT_CLI_LOCAL_AH_SCHEME, getCopilotCliSessionRawId, parseRemoteAuthorityFromScheme } from '../copilotCliEventsUri.js'; import { getRemoteConnectionForSession, sanitizeFilePart } from '../chatDebug/agentHostLogSources.js'; import { buildAgentHostCustomizationsUri, buildAgentHostUsageUri } from '../chatDebug/agentHostUsageSidecar.js'; -/** Output channel ID for the current window's renderer log. */ -const WINDOW_LOG_CHANNEL_ID = 'rendererLog'; -/** Output channel ID for the shared process compound log. */ -const SHARED_PROCESS_LOG_CHANNEL_ID = 'shared'; +const SHARED_PROCESS_LOG_FILE_NAME = 'sharedprocess.log'; +const OUTPUT_LOG_FOLDER_PREFIX = 'output_'; const MAX_INLINE_DEBUG_LOGS_BYTES = 30 * ByteSize.MB; /** @@ -67,7 +63,7 @@ export type IAgentHostDebugLogFile = export interface IAgentHostDebugLogsExport { readonly files: IAgentHostDebugLogFile[]; readonly exportName: string; - readonly hostArtifact: IAgentHostDebugLogsHostArtifact; + readonly hostArtifact: IAgentHostDebugLogsHostArtifact | undefined; } /** @@ -86,7 +82,7 @@ export const IAgentHostDebugLogsExportService = createDecorator; + save(exportName: string, files: readonly IAgentHostDebugLogFile[], hostArtifact: IAgentHostDebugLogsHostArtifact | undefined): Promise; } export class BrowserAgentHostDebugLogsExportService implements IAgentHostDebugLogsExportService { @@ -94,15 +90,32 @@ export class BrowserAgentHostDebugLogsExportService implements IAgentHostDebugLo readonly hostArtifactKind = 'directory'; constructor( - @IFileDialogService private readonly fileDialogService: IFileDialogService, - @IFileService private readonly fileService: IFileService, + @IInstantiationService private readonly instantiationService: IInstantiationService, ) { } - async save(exportName: string, files: readonly IAgentHostDebugLogFile[], hostArtifact: IAgentHostDebugLogsHostArtifact): Promise { - return exportFilesToLocalFolder(this.fileDialogService, this.fileService, exportName, files, hostArtifact); + async save(exportName: string, files: readonly IAgentHostDebugLogFile[], hostArtifact: IAgentHostDebugLogsHostArtifact | undefined): Promise { + return this.instantiationService.invokeFunction(accessor => exportFilesToLocalFolder(accessor, exportName, files, hostArtifact)); } } +export function resolveAgentHostDebugLogsChat( + activeSession: Pick, + state: SessionState | Error | undefined, +): { backendChat: URI | undefined; sessionTitle: string | undefined } { + let backendChat = activeSession.backendChatResource; + let sessionTitle = activeSession.sessionTitle; + if (state && !(state instanceof Error)) { + if (!backendChat) { + const backendChatResource = getSessionChatResource(state, activeSession.chatId); + if (backendChatResource) { + backendChat = URI.parse(backendChatResource); + } + } + sessionTitle ??= state.title; + } + return { backendChat, sessionTitle }; +} + /** * Streams a host-owned artifact by repeatedly calling `readChunk`. The stream * fails if the host overruns or underruns the size it declared, so a @@ -162,53 +175,46 @@ export async function collectAgentHostDebugLogs( const agentHostService = accessor.get(IAgentHostService); const agentHostConnectionsService = accessor.get(IAgentHostConnectionsService); const remoteAgentHostService = accessor.get(IRemoteAgentHostService); - const outputService = accessor.get(IOutputService); const fileService = accessor.get(IFileService); - const textModelService = accessor.get(ITextModelService); const logService = accessor.get(ILogService); - const environmentService = accessor.get(IEnvironmentService); + const environmentService = accessor.get(IWorkbenchEnvironmentService); const exportService = accessor.get(IAgentHostDebugLogsExportService); - let connection: IAgentConnection; + let connection: IAgentConnection | undefined; let backendSession: URI | undefined; let backendChat: URI | undefined; let sessionTitle = activeSession?.sessionTitle; if (activeSession) { const sessionResolution = agentHostConnectionsService.resolveSessionResource(activeSession.resource); if (!sessionResolution) { - throw new Error(`No live Agent Host connection owns session ${activeSession.resource.toString()}`); - } - connection = sessionResolution.connection; - backendSession = sessionResolution.backendSession; - backendChat = activeSession.backendChatResource; - if (!backendChat || !sessionTitle) { + logService.warn(`[ExportAgentHostDebugLogs] No live Agent Host connection owns session ${activeSession.resource.toString()}; exporting client-owned logs only`); + } else { + connection = sessionResolution.connection; + backendSession = sessionResolution.backendSession; const state = connection.getSubscriptionUnmanaged(StateComponents.Session, backendSession)?.value; + ({ backendChat, sessionTitle } = resolveAgentHostDebugLogsChat(activeSession, state)); if (!backendChat) { - if (!state || state instanceof Error) { - throw new Error(`Cannot resolve the active chat because session state is unavailable for ${activeSession.resource.toString()}`); - } - const backendChatResource = getSessionChatResource(state, activeSession.chatId); - if (!backendChatResource) { - throw new Error(`Cannot resolve active chat '${activeSession.chatId}' for ${activeSession.resource.toString()}`); - } - backendChat = URI.parse(backendChatResource); - } - if (state && !(state instanceof Error)) { - sessionTitle ??= state.title; + const reason = !state || state instanceof Error + ? 'session state is unavailable' + : `chat '${activeSession.chatId}' is unavailable`; + logService.warn(`[ExportAgentHostDebugLogs] Cannot resolve the active chat because ${reason} for ${activeSession.resource.toString()}; exporting session and client-owned logs`); } } } else { connection = agentHostConnectionsService.ambientConnection; } - // The Agent Host owns discovery and packaging of its own logs; failures - // surface to the user rather than being papered over by a second, - // path-guessing implementation on this side. - const hostArtifact = await connection.collectDebugLogs(backendSession, exportService.hostArtifactKind, backendChat); - onDidCreateHostArtifact(hostArtifact); + let hostArtifact: IAgentHostDebugLogsArtifact | undefined; + if (connection) { + try { + hostArtifact = await connection.collectDebugLogs(backendSession, exportService.hostArtifactKind, backendChat); + onDidCreateHostArtifact(hostArtifact); + } catch (error) { + logService.warn(`[ExportAgentHostDebugLogs] Failed to collect Agent Host logs: ${error instanceof Error ? error.message : String(error)}; exporting client-owned logs only`); + } + } let remainingInlineBytes = MAX_INLINE_DEBUG_LOGS_BYTES; - // Collect all output channel IDs relevant for the current session's agent host. - const channelIds = new Set(); + const forwardedAgentHostLogFileNames = new Set(); let ahpLogNameFilter: ((name: string) => boolean) | undefined; if (activeSession) { @@ -218,21 +224,17 @@ export async function collectAgentHostDebugLogs( } else { const remoteConnection = getRemoteConnectionForSession(activeSession.resource, remoteAgentHostService.connections); if (remoteConnection) { - channelIds.add(remoteAgentHostLogOutputChannelId(remoteConnection.address)); + forwardedAgentHostLogFileNames.add(getOutputChannelLogFileName(remoteAgentHostLogOutputChannelId(remoteConnection.address))); const remoteConnectionId = sanitizeFilePart(remoteConnection.address); ahpLogNameFilter = name => name.includes(remoteConnectionId); } } } else { for (const remoteConnection of remoteAgentHostService.connections) { - channelIds.add(remoteAgentHostLogOutputChannelId(remoteConnection.address)); + forwardedAgentHostLogFileNames.add(getOutputChannelLogFileName(remoteAgentHostLogOutputChannelId(remoteConnection.address))); } } - // Always include the window and shared process logs - channelIds.add(WINDOW_LOG_CHANNEL_ID); - channelIds.add(SHARED_PROCESS_LOG_CHANNEL_ID); - const files: IAgentHostDebugLogFile[] = []; const appendFile = (file: IAgentHostDebugLogFile) => { files.push(file); @@ -246,47 +248,28 @@ export async function collectAgentHostDebugLogs( } }; - // 1. Output channels - for (const channelId of channelIds) { - const channel = outputService.getChannel(channelId); - const descriptor = outputService.getChannelDescriptor(channelId); - if (!channel || !descriptor) { - continue; - } - const sources = isSingleSourceOutputChannelDescriptor(descriptor) - ? [descriptor.source] - : isMultiSourceOutputChannelDescriptor(descriptor) ? descriptor.source : []; - const channelFolderName = channelId === WINDOW_LOG_CHANNEL_ID - ? 'Window' - : channelId === SHARED_PROCESS_LOG_CHANNEL_ID ? 'Shared' : sanitizeFilePart(descriptor.label); - const channelFolder = `vscode-logs/${channelFolderName}`; - const sourceNames = sources.map(source => basename(source.resource)); - for (let index = 0; index < sources.length; index++) { - const source = sources[index]; - const sourceName = sourceNames[index]; - const sourceFolder = sourceNames.filter(name => name === sourceName).length > 1 - ? `${channelFolder}/${index + 1}-${sanitizeFilePart(source.name ?? sourceName)}` - : channelFolder; - try { - const collectedFiles = await collectRotatedLogFiles(sourceFolder, source.resource, fileService, remainingInlineBytes); - appendFiles(collectedFiles); - } catch (error) { - logService.warn(`[ExportAgentHostDebugLogs] Failed to collect rotated logs for '${source.resource.toString()}': ${error instanceof Error ? error.message : String(error)}`); - } - } - if (sources.length > 0) { - continue; - } - const modelRef = await textModelService.createModelReference(channel.uri); + // 1. Local VS Code process and forwarded Agent Host logs. + const processLogs = [ + { folder: 'Window', resource: environmentService.logFile }, + { folder: 'Shared', resource: joinPath(environmentService.logsHome, SHARED_PROCESS_LOG_FILE_NAME) }, + ]; + for (const processLog of processLogs) { try { - const filename = `${descriptor.label.replace(/[/\\:*?"<>|]/g, '-')}.log`; - const file = createInlineDebugLogFile(filename, VSBuffer.fromString(modelRef.object.textEditorModel.getValue()), remainingInlineBytes); + appendFiles(await collectRotatedLogFiles(`vscode-logs/${processLog.folder}`, processLog.resource, fileService, remainingInlineBytes)); + } catch (error) { + logService.warn(`[ExportAgentHostDebugLogs] Failed to collect rotated logs for '${processLog.resource.toString()}': ${error instanceof Error ? error.message : String(error)}`); + } + } + try { + const forwardedLogs = await findOutputChannelLogFiles(environmentService.windowLogsPath, forwardedAgentHostLogFileNames, fileService); + for (const forwardedLog of forwardedLogs) { + const file = await createDebugLogFile(`vscode-logs/Agent Host/${basename(forwardedLog)}`, forwardedLog, fileService, undefined, remainingInlineBytes); if (file) { appendFile(file); } - } finally { - modelRef.dispose(); } + } catch (error) { + logService.warn(`[ExportAgentHostDebugLogs] Failed to collect forwarded Agent Host logs: ${error instanceof Error ? error.message : String(error)}`); } // 2. AHP transport JSONL logs (one file per remote connection, written under /ahp/). @@ -338,7 +321,7 @@ export async function collectAgentHostDebugLogs( return { files, exportName: getAgentHostDebugLogsExportName(sessionTitle, activeSession?.chatTitle, activeSession?.chatId === DEFAULT_CHAT_ID), - hostArtifact: { artifact: hostArtifact, readChunk: createChunkReader(connection) }, + hostArtifact: hostArtifact && connection ? { artifact: hostArtifact, readChunk: createChunkReader(connection) } : undefined, }; } @@ -454,12 +437,14 @@ export function toActiveAgentHostSession(resource: URI, chatTitle: string | unde } async function exportFilesToLocalFolder( - fileDialogService: IFileDialogService, - fileService: IFileService, + accessor: ServicesAccessor, exportName: string, files: readonly IAgentHostDebugLogFile[], - hostArtifact: IAgentHostDebugLogsHostArtifact, + hostArtifact: IAgentHostDebugLogsHostArtifact | undefined, ): Promise { + const fileDialogService = accessor.get(IFileDialogService); + const fileService = accessor.get(IFileService); + const logService = accessor.get(ILogService); const folders = await fileDialogService.showOpenDialog({ title: localize('exportDebugLogs.folderDialogTitle', "Select Folder for Agent Host Debug Logs"), canSelectFiles: false, @@ -475,10 +460,16 @@ async function exportFilesToLocalFolder( const exportFolder = joinPath(parentFolder, exportName); await fileService.createFolder(exportFolder); - if (hostArtifact.artifact.kind !== 'directory') { - throw new Error(`Expected an Agent Host debug-log directory, got ${hostArtifact.artifact.kind}`); + if (hostArtifact) { + try { + if (hostArtifact.artifact.kind !== 'directory') { + throw new Error(`Expected an Agent Host debug-log directory, got ${hostArtifact.artifact.kind}`); + } + await copyHostArtifactDirectory(exportFolder, hostArtifact, fileService); + } catch (error) { + logService.warn(`[ExportAgentHostDebugLogs] Failed to save Agent Host logs: ${error instanceof Error ? error.message : String(error)}; saving client-owned logs only`); + } } - await copyHostArtifactDirectory(exportFolder, hostArtifact, fileService); for (const file of files) { const segments = toSafeRelativePathSegments(file.path); if (segments.length === 0) { @@ -582,6 +573,34 @@ export async function collectRotatedLogFiles(path: string, current: URI, fileSer return files; } +export async function findOutputChannelLogFiles(windowLogsPath: URI, fileNames: ReadonlySet, fileService: IFileService): Promise { + if (fileNames.size === 0) { + return []; + } + const windowLogs = await fileService.resolve(windowLogsPath); + const outputFolders = (windowLogs.children ?? []) + .filter(child => child.isDirectory && child.name.startsWith(OUTPUT_LOG_FOLDER_PREFIX)) + .sort((a, b) => b.name.localeCompare(a.name)); + const remaining = new Set(fileNames); + const result: URI[] = []; + for (const outputFolder of outputFolders) { + const folder = await fileService.resolve(outputFolder.resource); + for (const child of folder.children ?? []) { + if (child.isFile && !child.isSymbolicLink && remaining.delete(child.name)) { + result.push(child.resource); + } + } + if (remaining.size === 0) { + break; + } + } + return result; +} + +function getOutputChannelLogFileName(channelId: string): string { + return `${channelId.replace(/[\\/:\*\?"<>\|]/g, '')}.log`; +} + function isRotatedLogFile(candidate: string, current: string): boolean { if (candidate === current) { return true; diff --git a/src/vs/workbench/contrib/chat/electron-browser/actions/exportAgentHostDebugLogsService.ts b/src/vs/workbench/contrib/chat/electron-browser/actions/exportAgentHostDebugLogsService.ts index d1bb5528c62..714b0c4bf66 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/actions/exportAgentHostDebugLogsService.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/actions/exportAgentHostDebugLogsService.ts @@ -30,7 +30,7 @@ class NativeAgentHostDebugLogsExportService implements IAgentHostDebugLogsExport @ILogService private readonly logService: ILogService, ) { } - async save(exportName: string, files: readonly IAgentHostDebugLogFile[], hostArtifact: IAgentHostDebugLogsHostArtifact): Promise { + async save(exportName: string, files: readonly IAgentHostDebugLogFile[], hostArtifact: IAgentHostDebugLogsHostArtifact | undefined): Promise { const defaultUri = joinPath(await this.fileDialogService.preferredHome(Schemas.file), `${exportName}.zip`); const saveUri = await this.fileDialogService.showSaveDialog({ title: localize('exportDebugLogs.saveDialogTitle', "Export Agent Host Debug Logs"), @@ -48,25 +48,40 @@ class NativeAgentHostDebugLogsExportService implements IAgentHostDebugLogsExport ? file : { path: file.path, source: file.resource.scheme === Schemas.vscodeUserData ? file.resource.with({ scheme: Schemas.file }) : file.resource, size: file.size, skipSourceErrors: true }; }); + const zipOptions = { maxEntries: AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES }; + let hostArchiveIncluded = false; let temporaryHostArchive: URI | undefined; try { - const { artifact, readChunk } = hostArtifact; - if (artifact.kind !== 'archive') { - throw new Error(`Expected an Agent Host debug-log archive, got ${artifact.kind}`); + if (hostArtifact) { + try { + const { artifact, readChunk } = hostArtifact; + if (artifact.kind !== 'archive') { + throw new Error(`Expected an Agent Host debug-log archive, got ${artifact.kind}`); + } + let localHostArchive = artifact.resource; + if (artifact.resource.scheme !== Schemas.file) { + // The archive lives on a remote agent host. Stream it down in + // bounded chunks rather than pulling the whole thing over in a + // single protocol message. + localHostArchive = joinPath(this.environmentService.tmpDir, `agent-host-debug-logs-${generateUuid()}.zip`); + temporaryHostArchive = localHostArchive; + await this.fileService.writeFile(localHostArchive, createHostArtifactStream(artifact, position => readChunk(artifact.resource, position))); + } + zipFiles.push({ sourceArchive: localHostArchive }); + hostArchiveIncluded = true; + } catch (error) { + this.logService.warn(`[ExportAgentHostDebugLogs] Failed to save Agent Host logs: ${error instanceof Error ? error.message : String(error)}; saving client-owned logs only`); + } } - let localHostArchive = artifact.resource; - if (artifact.resource.scheme !== Schemas.file) { - // The archive lives on a remote agent host. Stream it down in - // bounded chunks rather than pulling the whole thing over in a - // single protocol message. - localHostArchive = joinPath(this.environmentService.tmpDir, `agent-host-debug-logs-${generateUuid()}.zip`); - temporaryHostArchive = localHostArchive; - await this.fileService.writeFile(localHostArchive, createHostArtifactStream(artifact, position => readChunk(artifact.resource, position))); + try { + await this.nativeHostService.createZipFile(saveUri, zipFiles, zipOptions); + } catch (error) { + if (!hostArchiveIncluded) { + throw error; + } + this.logService.warn(`[ExportAgentHostDebugLogs] Failed to merge Agent Host logs: ${error instanceof Error ? error.message : String(error)}; saving client-owned logs only`); + await this.nativeHostService.createZipFile(saveUri, zipFiles.slice(0, -1), zipOptions); } - zipFiles.push({ sourceArchive: localHostArchive }); - await this.nativeHostService.createZipFile(saveUri, zipFiles, { - maxEntries: AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, - }); } finally { if (temporaryHostArchive) { // Best-effort: the download may have failed before the file was diff --git a/src/vs/workbench/contrib/chat/test/browser/exportAgentHostDebugLogs.test.ts b/src/vs/workbench/contrib/chat/test/browser/exportAgentHostDebugLogs.test.ts index f94088dd780..070903e4a4d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/exportAgentHostDebugLogs.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/exportAgentHostDebugLogs.test.ts @@ -14,7 +14,7 @@ import { buildChatUri, buildDefaultChatUri, getSessionChatResource } from '../.. import { FileService } from '../../../../../platform/files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../../../platform/files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; -import { collectRotatedLogFiles, createHostArtifactStream, getAgentHostDebugLogsExportName, toActiveAgentHostSession } from '../../browser/actions/exportAgentHostDebugLogsAction.js'; +import { collectRotatedLogFiles, createHostArtifactStream, findOutputChannelLogFiles, getAgentHostDebugLogsExportName, resolveAgentHostDebugLogsChat, toActiveAgentHostSession } from '../../browser/actions/exportAgentHostDebugLogsAction.js'; function artifactOfSize(size: number): IAgentHostDebugLogsArtifact { return { @@ -118,6 +118,19 @@ suite('toActiveAgentHostSession', () => { missing: undefined, }); }); + + test('continues without an active chat when session state is unavailable', () => { + const activeSession = toActiveAgentHostSession(URI.parse('remote-test-copilotcli:/session-1#side-chat'), 'Side chat', 'Session one'); + assert.ok(activeSession); + + assert.deepStrictEqual({ + unavailable: resolveAgentHostDebugLogsChat(activeSession, undefined), + failed: resolveAgentHostDebugLogsChat(activeSession, new Error('disconnected')), + }, { + unavailable: { backendChat: undefined, sessionTitle: 'Session one' }, + failed: { backendChat: undefined, sessionTitle: 'Session one' }, + }); + }); }); suite('collectRotatedLogFiles', () => { @@ -172,6 +185,26 @@ suite('collectRotatedLogFiles', () => { }); }); + test('finds the newest matching output channel backing files', async () => { + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); + const windowLogs = URI.file('/logs/window1'); + const oldOutput = URI.joinPath(windowLogs, 'output_20260825T080000'); + const newOutput = URI.joinPath(windowLogs, 'output_20260825T090000'); + await Promise.all([fileService.createFolder(oldOutput), fileService.createFolder(newOutput)]); + await Promise.all([ + fileService.writeFile(URI.joinPath(oldOutput, 'agentHost.otlp.remote.log'), VSBuffer.fromString('old')), + fileService.writeFile(URI.joinPath(newOutput, 'agentHost.otlp.remote.log'), VSBuffer.fromString('new')), + fileService.writeFile(URI.joinPath(newOutput, 'unrelated.log'), VSBuffer.fromString('unrelated')), + ]); + + const files = await findOutputChannelLogFiles(windowLogs, new Set(['agentHost.otlp.remote.log']), fileService); + + assert.deepStrictEqual(files.map(file => file.toString()), [ + 'file:///logs/window1/output_20260825T090000/agentHost.otlp.remote.log', + ]); + }); + test('collects local user data logs as resources', async () => { const fileService = disposables.add(new FileService(new NullLogService())); disposables.add(fileService.registerProvider(Schemas.vscodeUserData, disposables.add(new InMemoryFileSystemProvider()))); From dc7793b9072f26ffb639804b0d6ee3b0491476f8 Mon Sep 17 00:00:00 2001 From: koubaki <165947889+koubaki@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:16:40 +0300 Subject: [PATCH 017/116] Merge pull request #329157 from koubaki/patch-1 Update error message in inlineChatIntent.ts --- .../copilot/src/extension/inlineChat2/node/inlineChatIntent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/copilot/src/extension/inlineChat2/node/inlineChatIntent.ts b/extensions/copilot/src/extension/inlineChat2/node/inlineChatIntent.ts index b117816f1ff..d72bcfb0e7a 100644 --- a/extensions/copilot/src/extension/inlineChat2/node/inlineChatIntent.ts +++ b/extensions/copilot/src/extension/inlineChat2/node/inlineChatIntent.ts @@ -515,7 +515,7 @@ class InlineChatToolCalling { if (result.hasError) { failedEdits.push([toolCall, result]); - stream.progress(l10n.t('Looking not yet good, trying again...')); + stream.progress(l10n.t('An error occurred, trying again...')); } this._logService.trace(`Tool ${toolCall.name} invocation result: ${JSON.stringify(result)}`); From 4d05c01ff9ad8130ee4d7307f473fc0e1ffdaafb Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:16:55 +0000 Subject: [PATCH 018/116] Rename from the session header uses the inline title input (#332574) * Initial plan * Rename from the session header uses the inline title input Co-authored-by: benibenj <44439583+benibenj@users.noreply.github.com> * Keep the edit icon on the session header rename entry Co-authored-by: benibenj <44439583+benibenj@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: benibenj <44439583+benibenj@users.noreply.github.com> --- .../sessions/browser/parts/sessionHeader.ts | 18 +++++- src/vs/sessions/browser/parts/sessionView.ts | 10 +++- .../sessions/browser/sessionsActions.ts | 22 +++++-- .../browser/views/sessionsViewActions.ts | 5 -- .../test/browser/sessionsActions.test.ts | 4 +- .../test/browser/sessionsRename.test.ts | 59 ++++++++++++++++++- .../test/browser/sessionHeader.test.ts | 29 ++++++++- 7 files changed, 127 insertions(+), 20 deletions(-) diff --git a/src/vs/sessions/browser/parts/sessionHeader.ts b/src/vs/sessions/browser/parts/sessionHeader.ts index e47f5fd7a91..ef5200b8773 100644 --- a/src/vs/sessions/browser/parts/sessionHeader.ts +++ b/src/vs/sessions/browser/parts/sessionHeader.ts @@ -295,11 +295,23 @@ export class SessionHeader extends Disposable { return !!this._session && (this._session.capabilities.get().supportsRename ?? false); } - startTitleEditing(): void { - if (!this._isTitleEditable() || this._renameInput) { - return; + /** + * Starts an inline rename of the session title. Returns `false` when the + * header cannot host it — the header is hidden (e.g. while the single-group + * chat tabs row replaces it) or the session cannot be renamed — so callers + * can fall back to another rename affordance. + */ + startTitleEditing(): boolean { + if (!this._visible || !this._isTitleEditable()) { + return false; + } + if (this._renameInput) { + this._renameInput.focus(); + this._renameInput.select(); + return true; } this._startTitleEditing(); + return true; } /** diff --git a/src/vs/sessions/browser/parts/sessionView.ts b/src/vs/sessions/browser/parts/sessionView.ts index 77bbb0b6299..9cc5f8c22a5 100644 --- a/src/vs/sessions/browser/parts/sessionView.ts +++ b/src/vs/sessions/browser/parts/sessionView.ts @@ -252,8 +252,14 @@ export class SessionView extends Disposable implements ISerializableView { standaloneView ? standaloneView.focus() : this._groupsView.focus(); } - startTitleEditing(): void { - this._header.startTitleEditing(); + /** + * Starts an inline rename of the session title in the header. Returns + * `false` when the header cannot host it (e.g. this view is hidden or the + * chat tabs row replaces the header) so callers can fall back to another + * rename affordance. + */ + startTitleEditing(): boolean { + return this._isVisible && this._header.startTitleEditing(); } selectWorkspace(folderUri: URI, providerId?: string): void { diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index 4a88943e25b..bbc243d953e 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -13,6 +13,7 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuRegistry, MenuId, registerAction2, MenuItemAction } from '../../../../platform/actions/common/actions.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { InputFocusedContext } from '../../../../platform/contextkey/common/contextkeys.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; @@ -26,9 +27,9 @@ import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/l import { getQuickNavigateHandler, inQuickPickContext } from '../../../../workbench/browser/quickaccess.js'; import { Menus } from '../../../browser/menus.js'; import { SessionsCategories } from '../../../common/categories.js'; -import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext, SessionActiveChatIsClosableContext, SessionActiveChatIsDeletableContext, SessionChatsPickerVisibleContext, SessionActiveChatHasSubagentsContext, SessionsTitleBarNewSessionEnabledContext, SessionsEditorScopeContext, SessionsHasClosedItemContext, IsQuickChatSessionContext } from '../../../common/contextkeys.js'; +import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionSupportsRenameContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext, SessionActiveChatIsClosableContext, SessionActiveChatIsDeletableContext, SessionChatsPickerVisibleContext, SessionActiveChatHasSubagentsContext, SessionsTitleBarNewSessionEnabledContext, SessionsEditorScopeContext, SessionsHasClosedItemContext, IsQuickChatSessionContext } from '../../../common/contextkeys.js'; import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsProvider.js'; -import { CLOSE_CHAT_COMMAND_ID, FOCUS_ACTIVE_SESSION_COMMAND_ID, FOCUS_NEXT_CHAT_GROUP_COMMAND_ID, FOCUS_PREVIOUS_CHAT_GROUP_COMMAND_ID, MOVE_CHAT_TO_NEXT_GROUP_COMMAND_ID, MOVE_CHAT_TO_PREVIOUS_GROUP_COMMAND_ID, SPLIT_CHAT_GROUP_DOWN_COMMAND_ID, SPLIT_CHAT_GROUP_RIGHT_COMMAND_ID } from '../../../common/sessionCommands.js'; +import { CLOSE_CHAT_COMMAND_ID, FOCUS_ACTIVE_SESSION_COMMAND_ID, FOCUS_NEXT_CHAT_GROUP_COMMAND_ID, FOCUS_PREVIOUS_CHAT_GROUP_COMMAND_ID, MOVE_CHAT_TO_NEXT_GROUP_COMMAND_ID, MOVE_CHAT_TO_PREVIOUS_GROUP_COMMAND_ID, RENAME_SESSION_COMMAND_ID, SPLIT_CHAT_GROUP_DOWN_COMMAND_ID, SPLIT_CHAT_GROUP_RIGHT_COMMAND_ID } from '../../../common/sessionCommands.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ChatOriginKind, getChatCapabilities, getUntitledSessionTitle, IChat, ISession, SessionStatus } from '../../../services/sessions/common/session.js'; @@ -1430,20 +1431,33 @@ registerAction2(class RenameSessionHeaderAction extends Action2 { super({ id: 'sessions.sessionHeader.rename', title: localize2('renameSessionHeader', "Rename..."), + icon: Codicon.edit, menu: [{ id: Menus.SessionHeaderContext, group: '2_edit', order: 1, when: ContextKeyExpr.regex(SessionProviderIdContext.key, ANY_AGENT_HOST_PROVIDER_RE), + }, { + id: Menus.SessionBarToolbar, + group: 'secondary/1_session', + order: 20, + when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsRenameContext, SessionIsArchivedContext.negate()), }], }); } - override run(accessor: ServicesAccessor, session: IActiveSession | undefined): void { + override async run(accessor: ServicesAccessor, session: IActiveSession | undefined): Promise { if (!session) { return; } - accessor.get(ISessionsPartService).getSessionView(session.sessionId)?.startTitleEditing(); + // Renaming in the header title is the lightest-weight affordance, but it + // is only available while the header shows the title (e.g. not while the + // single-group chat tabs row replaces it); prompt for the new title when + // it cannot be used. + if (accessor.get(ISessionsPartService).getSessionView(session.sessionId)?.startTitleEditing()) { + return; + } + await accessor.get(ICommandService).executeCommand(RENAME_SESSION_COMMAND_ID, session); } }); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index a0afad0028f..e4fab13d289 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -975,11 +975,6 @@ registerAction2(class RenameSessionAction extends Action2 { group: '1_edit', order: 1, when: SessionSupportsRenameContext, - }, { - id: Menus.SessionBarToolbar, - group: 'secondary/1_session', - order: 20, - when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsRenameContext, SessionIsArchivedContext.negate()), }] }); } diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts index b1054275286..810175c6652 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts @@ -54,12 +54,12 @@ suite('Sessions - Actions', () => { test('groups session management actions before creation and close', () => { const actions = MenuRegistry.getMenuItems(Menus.SessionBarToolbar) .filter(isIMenuItem) - .filter(item => item.command.id === 'sessions.chatCompositeBar.togglePin' || item.command.id === 'sessionsViewPane.renameSession' || item.command.id === 'sessions.chatCompositeBar.addChat' || item.command.id === 'sessions.chatCompositeBar.close') + .filter(item => item.command.id === 'sessions.chatCompositeBar.togglePin' || item.command.id === 'sessions.sessionHeader.rename' || item.command.id === 'sessions.chatCompositeBar.addChat' || item.command.id === 'sessions.chatCompositeBar.close') .sort((a, b) => (a.group ?? '').localeCompare(b.group ?? '') || (a.order ?? 0) - (b.order ?? 0)) .map(item => ({ id: item.command.id, group: item.group })); assert.deepStrictEqual(actions, [ - { id: 'sessionsViewPane.renameSession', group: 'secondary/1_session' }, + { id: 'sessions.sessionHeader.rename', group: 'secondary/1_session' }, { id: 'sessions.chatCompositeBar.addChat', group: 'secondary/2_chats' }, { id: 'sessions.chatCompositeBar.togglePin', group: 'secondary/3_pin' }, { id: 'sessions.chatCompositeBar.close', group: 'secondary/3_pin' }, diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts index e60e2da74f9..8ff9be2709d 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts @@ -9,7 +9,7 @@ import { constObservable } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; +import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IInputOptions, IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; import { RENAME_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; @@ -19,7 +19,8 @@ import { ISessionsService } from '../../../../services/sessions/browser/sessions import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { SessionsChatAccessibilityHelp } from '../../../chat/browser/sessionsChatAccessibilityHelp.js'; import { SessionsFlatList, SessionsGrouping, SessionsList, SessionsSorting } from '../../browser/views/sessionsList.js'; -import { createListHarness, createTestSession, TestSessionsManagementService } from './sessionsListTestUtils.js'; +import { createListHarness, createTestSession, TestCommandService, TestSessionsManagementService } from './sessionsListTestUtils.js'; +import '../../browser/sessionsActions.js'; import '../../browser/views/sessionsViewActions.js'; class TestQuickInputService extends mock() { @@ -208,6 +209,60 @@ suite('Sessions rename', () => { }); }); + suite('session header action', () => { + function createHeaderHarness(inlineRename: boolean | undefined) { + const instantiationService = disposables.add(new TestInstantiationService()); + const commandService = new TestCommandService(); + const sessionData = createTestSession('Existing'); + let inlineRenameCalls = 0; + instantiationService.stub(ICommandService, commandService); + instantiationService.stub(ISessionsPartService, new class extends mock() { + override getSessionView() { + if (inlineRename === undefined) { + return undefined; + } + return new class extends mock() { + override startTitleEditing(): boolean { + inlineRenameCalls++; + return inlineRename; + } + }; + } + }); + const handler = CommandsRegistry.getCommand('sessions.sessionHeader.rename')?.handler; + assert.ok(handler); + return { handler, instantiationService, commandService, session: sessionData.session, inlineRenameCalls: () => inlineRenameCalls }; + } + + test('renames inline in the header and only prompts when that is not possible', async () => { + const inline = createHeaderHarness(true); + await inline.handler(inline.instantiationService, inline.session); + + // The header cannot show the title (e.g. the chat tabs row replaced it). + const headerUnavailable = createHeaderHarness(false); + await headerUnavailable.handler(headerUnavailable.instantiationService, headerUnavailable.session); + + // The session is not shown in the sessions part at all. + const noView = createHeaderHarness(undefined); + await noView.handler(noView.instantiationService, noView.session); + + const withoutSession = createHeaderHarness(true); + await withoutSession.handler(withoutSession.instantiationService, undefined); + + assert.deepStrictEqual({ + inline: { calls: inline.inlineRenameCalls(), prompts: inline.commandService.calls }, + headerUnavailable: { calls: headerUnavailable.inlineRenameCalls(), prompts: headerUnavailable.commandService.calls }, + noView: { calls: noView.inlineRenameCalls(), prompts: noView.commandService.calls }, + withoutSession: { calls: withoutSession.inlineRenameCalls(), prompts: withoutSession.commandService.calls }, + }, { + inline: { calls: 1, prompts: [] }, + headerUnavailable: { calls: 1, prompts: [{ commandId: RENAME_SESSION_COMMAND_ID, args: [headerUnavailable.session] }] }, + noView: { calls: 0, prompts: [{ commandId: RENAME_SESSION_COMMAND_ID, args: [noView.session] }] }, + withoutSession: { calls: 0, prompts: [] }, + }); + }); + }); + suite('accessibility help', () => { function createHelpProvider(origin: HTMLElement, removeOrigin = false) { const instantiationService = disposables.add(new TestInstantiationService()); diff --git a/src/vs/sessions/test/browser/sessionHeader.test.ts b/src/vs/sessions/test/browser/sessionHeader.test.ts index c8f30be9d17..1392a046e48 100644 --- a/src/vs/sessions/test/browser/sessionHeader.test.ts +++ b/src/vs/sessions/test/browser/sessionHeader.test.ts @@ -21,7 +21,7 @@ import { ISessionsService } from '../../services/sessions/browser/sessionsServic import { IChat, ISessionCapabilities, SessionStatus } from '../../services/sessions/common/session.js'; import { IActiveSession, ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js'; -function createHarness(disposables: Pick) { +function createHarness(disposables: Pick, capabilities: ISessionCapabilities = { supportsMultipleChats: false }) { const store = disposables.add(new DisposableStore()); const instantiationService = workbenchInstantiationService(undefined, store); @@ -60,7 +60,7 @@ function createHarness(disposables: Pick) { override readonly closedChats: IObservable = constObservable([]); override readonly visibleChatTabs: IObservable = constObservable([mainChat]); override readonly shouldShowChatTabs: IObservable = constObservable(false); - override readonly capabilities: IObservable = constObservable({ supportsMultipleChats: false }); + override readonly capabilities: IObservable = constObservable(capabilities); }(); const header = store.add(instantiationService.createInstance(SessionHeader)); @@ -121,4 +121,29 @@ suite('Sessions - SessionHeader', () => { hasMetadataRow: false, }); }); + + test('reports whether the inline rename could be started', () => { + const renameable = createHarness(disposables, { supportsMultipleChats: false, supportsRename: true }); + const notRenameable = createHarness(disposables); + + const startedWhenVisible = renameable.header.startTitleEditing(); + const hasInput = renameable.header.element.querySelector('.chat-composite-bar-session-title-input') !== null; + // The header is hidden while the single-group tabs row replaces it, so + // there is no title to rename inline. + renameable.header.setVisible(false); + + assert.deepStrictEqual({ + startedWhenVisible, + hasInput, + startedWhenHidden: renameable.header.startTitleEditing(), + startedWhenNotRenameable: notRenameable.header.startTitleEditing(), + hasInputWhenNotRenameable: notRenameable.header.element.querySelector('.chat-composite-bar-session-title-input') !== null, + }, { + startedWhenVisible: true, + hasInput: true, + startedWhenHidden: false, + startedWhenNotRenameable: false, + hasInputWhenNotRenameable: false, + }); + }); }); From a386ecaceb09b31e2ff52c6e50d4e1e231b04a4f Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 25 Aug 2026 15:17:19 -0700 Subject: [PATCH 019/116] mcp: normalize install URI configurations (#332638) Normalize MCP server configurations from vscode:mcp/install links before the install editor opens. - Infer the server type when the supplied type is not supported. - Keep only properties supported by the selected local or remote transport. - Reject payloads that omit the required server name, command, or URL. - Add tests for unsupported types and cross-transport properties. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mcp/browser/mcpWorkbenchService.ts | 158 ++++++++++++++++-- .../test/browser/mcpWorkbenchService.test.ts | 68 +++++++- 2 files changed, 213 insertions(+), 13 deletions(-) diff --git a/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts b/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts index 6ec64801179..36ab12131ee 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts @@ -9,7 +9,7 @@ import { createCommandUri, IMarkdownString, MarkdownString } from '../../../../b import { Disposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { basename } from '../../../../base/common/resources.js'; -import { Mutable } from '../../../../base/common/types.js'; +import { isBoolean, isNumber, isObject, isString, isStringArray } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -21,7 +21,7 @@ import { ILabelService } from '../../../../platform/label/common/label.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IGalleryMcpServer, IMcpGalleryService, IQueryOptions, IInstallableMcpServer, IGalleryMcpServerConfiguration, mcpAccessConfig, McpAccessValue, IAllowedMcpServersService, IMcpGalleryServerResolveResult, McpGalleryResolveStatus } from '../../../../platform/mcp/common/mcpManagement.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { IMcpServerConfiguration, IMcpServerVariable, IMcpStdioServerConfiguration, McpServerType } from '../../../../platform/mcp/common/mcpPlatformTypes.js'; +import { IMcpDevModeConfig, IMcpRemoteServerConfiguration, IMcpServerConfiguration, IMcpServerVariable, IMcpStdioServerConfiguration, McpServerType } from '../../../../platform/mcp/common/mcpPlatformTypes.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { StorageScope } from '../../../../platform/storage/common/storage.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; @@ -50,6 +50,149 @@ interface IMcpServerStateProvider { (mcpWorkbenchServer: McpWorkbenchServer): T; } +interface IMcpInstallUriPayload { + readonly name: string; + readonly config: IMcpServerConfiguration; + readonly inputs?: IMcpServerVariable[]; +} + +function parseMcpInstallUriPayload(query: string): IMcpInstallUriPayload | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(decodeURIComponent(query)); + } catch { + return undefined; + } + + if (!isObject(parsed)) { + return undefined; + } + + const payload = parsed as Record; + if (!isString(payload.name) || !payload.name) { + return undefined; + } + + const config = sanitizeMcpServerConfiguration(payload); + if (!config) { + return undefined; + } + + return { + name: payload.name, + config, + inputs: Array.isArray(payload.inputs) ? payload.inputs as IMcpServerVariable[] : undefined, + }; +} + +function sanitizeMcpServerConfiguration(payload: Record): IMcpServerConfiguration | undefined { + const type = payload.type === McpServerType.LOCAL || payload.type === McpServerType.REMOTE + ? payload.type + : isString(payload.command) + ? McpServerType.LOCAL + : McpServerType.REMOTE; + const dev = sanitizeMcpDevModeConfig(payload.dev); + + const common = { + ...(isString(payload.version) ? { version: payload.version } : {}), + ...(isBoolean(payload.gallery) || isString(payload.gallery) ? { gallery: payload.gallery } : {}), + ...(dev ? { dev } : {}), + }; + + if (type === McpServerType.LOCAL) { + if (!isString(payload.command)) { + return undefined; + } + const env = sanitizeMcpEnvironment(payload.env); + + return { + type, + command: payload.command, + ...common, + ...(isStringArray(payload.args) ? { args: payload.args } : {}), + ...(env ? { env } : {}), + ...(isString(payload.envFile) ? { envFile: payload.envFile } : {}), + ...(isString(payload.cwd) ? { cwd: payload.cwd } : {}), + ...(isBoolean(payload.sandboxEnabled) ? { sandboxEnabled: payload.sandboxEnabled } : {}), + } satisfies IMcpStdioServerConfiguration; + } + + if (!isString(payload.url)) { + return undefined; + } + const headers = sanitizeStringRecord(payload.headers); + const oauth = sanitizeMcpOAuthConfiguration(payload.oauth); + + return { + type, + url: payload.url, + ...common, + ...(payload.transport === 'http' || payload.transport === 'sse' ? { transport: payload.transport } : {}), + ...(headers ? { headers } : {}), + ...(oauth ? { oauth } : {}), + } satisfies IMcpRemoteServerConfiguration; +} + +function sanitizeMcpDevModeConfig(value: unknown): IMcpDevModeConfig | undefined { + if (!isObject(value)) { + return undefined; + } + + const payload = value as Record; + const debug = sanitizeMcpDevModeDebugConfiguration(payload.debug); + if (!isString(payload.watch) && !isStringArray(payload.watch) && !debug) { + return undefined; + } + + return { + ...(isString(payload.watch) || isStringArray(payload.watch) ? { watch: payload.watch } : {}), + ...(debug ? { debug } : {}), + }; +} + +function sanitizeMcpDevModeDebugConfiguration(value: unknown): IMcpDevModeConfig['debug'] | undefined { + if (!isObject(value)) { + return undefined; + } + + const payload = value as Record; + if (payload.type === 'node') { + return { type: 'node' }; + } + if (payload.type === 'debugpy') { + return { + type: 'debugpy', + ...(isString(payload.debugpyPath) ? { debugpyPath: payload.debugpyPath } : {}), + }; + } + return undefined; +} + +function sanitizeMcpEnvironment(value: unknown): Record | undefined { + return sanitizeRecord(value, entry => entry === null || isString(entry) || isNumber(entry)); +} + +function sanitizeStringRecord(value: unknown): Record | undefined { + return sanitizeRecord(value, isString); +} + +function sanitizeRecord(value: unknown, isValidValue: (entry: unknown) => entry is T): Record | undefined { + if (!isObject(value)) { + return undefined; + } + + return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, T] => isValidValue(entry[1]))); +} + +function sanitizeMcpOAuthConfiguration(value: unknown): IMcpRemoteServerConfiguration['oauth'] | undefined { + if (!isObject(value)) { + return undefined; + } + + const payload = value as Record; + return isString(payload.clientId) ? { clientId: payload.clientId } : undefined; +} + class McpWorkbenchServer implements IWorkbenchMcpServer { constructor( @@ -747,15 +890,13 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ } private async handleMcpInstallUri(uri: URI): Promise { - let parsed: IMcpServerConfiguration & { name: string; inputs?: IMcpServerVariable[] }; - try { - parsed = JSON.parse(decodeURIComponent(uri.query)); - } catch (e) { + const parsed = parseMcpInstallUriPayload(uri.query); + if (!parsed) { return false; } try { - const { name, inputs, ...config } = parsed; + const { name, inputs, config } = parsed; // When a gallery field is present and the gallery service is available, // verify the server exists in the active gallery by name. If verified, @@ -778,9 +919,6 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ } } - if (config.type === undefined) { - (>config).type = (parsed).command ? McpServerType.LOCAL : McpServerType.REMOTE; - } this.open(this.instantiationService.createInstance(McpWorkbenchServer, e => this.getInstallState(e), e => this.getRuntimeStatus(e), undefined, undefined, { name, config, inputs })); } catch (e) { // ignore diff --git a/src/vs/workbench/contrib/mcp/test/browser/mcpWorkbenchService.test.ts b/src/vs/workbench/contrib/mcp/test/browser/mcpWorkbenchService.test.ts index 8683f15f2a0..036ea43e64c 100644 --- a/src/vs/workbench/contrib/mcp/test/browser/mcpWorkbenchService.test.ts +++ b/src/vs/workbench/contrib/mcp/test/browser/mcpWorkbenchService.test.ts @@ -34,6 +34,7 @@ import { IWorkbenchLocalMcpServer, IWorkbenchMcpManagementService, IWorkbenchMcp import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; import { TestProductService } from '../../../../test/common/workbenchTestServices.js'; import { IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js'; +import { McpServerEditorInput } from '../../browser/mcpServerEditorInput.js'; import { McpWorkbenchService } from '../../browser/mcpWorkbenchService.js'; import { IMcpService } from '../../common/mcpTypes.js'; @@ -222,7 +223,7 @@ function notFound(): IMcpGalleryServerResolveResult { return { status: McpGalleryResolveStatus.NotFound }; } -suite('McpWorkbenchService - registry-only enforcement', () => { +suite('McpWorkbenchService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -233,11 +234,19 @@ suite('McpWorkbenchService - registry-only enforcement', () => { managementService.installed = [...installed]; const configurationService = new TestConfigurationService({ [mcpAccessConfig]: accessValue }); const allowedMcpServersEmitter = store.add(new Emitter()); + const openedEditors: McpServerEditorInput[] = []; const services = new ServiceCollection( [IMcpGalleryManifestService, manifestService], [IMcpGalleryService, galleryService], [IWorkbenchMcpManagementService, managementService], - [IEditorService, upcastPartial({})], + [IEditorService, upcastPartial({ + openEditor: async editor => { + if (editor instanceof McpServerEditorInput) { + openedEditors.push(store.add(editor)); + } + return undefined; + } + })], [IUserDataProfilesService, upcastPartial({ profiles: [] })], [IUriIdentityService, upcastPartial({})], [IWorkspaceContextService, upcastPartial({})], @@ -257,7 +266,7 @@ suite('McpWorkbenchService - registry-only enforcement', () => { const instantiationService = store.add(new TestInstantiationService(services)); const service = store.add(instantiationService.createInstance(McpWorkbenchService)); await Event.toPromise(service.onChange); - return { service, galleryService, manifestService, managementService, allowedMcpServersEmitter }; + return { service, galleryService, manifestService, managementService, allowedMcpServersEmitter, openedEditors }; } async function complete(request: IResolveRequest, result: Map): Promise { @@ -266,6 +275,59 @@ suite('McpWorkbenchService - registry-only enforcement', () => { await timeout(0); } + test('sanitizes local MCP server configurations from install URIs', async () => { + const { service, openedEditors } = await createFixture([]); + const uri = URI.parse(`vscode:mcp/install?${encodeURIComponent(JSON.stringify({ + name: 'local-server', + type: 'invalid', + command: '/bin/sh', + args: ['-c', 'open -a Calculator'], + unknown: 'value', + url: 'https://example.com/mcp', + }))}`); + + const handled = await service.handleURL(uri); + + assert.deepStrictEqual({ + handled, + config: openedEditors[0]?.mcpServer.config, + }, { + handled: true, + config: { + type: McpServerType.LOCAL, + command: '/bin/sh', + args: ['-c', 'open -a Calculator'], + }, + }); + }); + + test('strips local and unknown properties from remote MCP server install URIs', async () => { + const { service, openedEditors } = await createFixture([]); + const uri = URI.parse(`vscode:mcp/install?${encodeURIComponent(JSON.stringify({ + name: 'remote-server', + type: McpServerType.REMOTE, + url: 'https://example.com/mcp', + headers: { Authorization: 'Bearer token' }, + command: '/bin/sh', + args: ['-c', 'open -a Calculator'], + unknown: 'value', + }))}`); + + const handled = await service.handleURL(uri); + + assert.deepStrictEqual({ + handled, + config: openedEditors[0]?.mcpServer.config, + }, { + handled: true, + config: { + type: McpServerType.REMOTE, + url: 'https://example.com/mcp', + headers: { Authorization: 'Bearer token' }, + }, + }); + }); + test('enables only manually configured servers found in the registry', async () => { const foundLocal = createLocal('found'); const missingLocal = createLocal('missing'); From 2c67b972cd797f05608a35a744ca39c6aacbb191 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 25 Aug 2026 15:17:33 -0700 Subject: [PATCH 020/116] agentHost: apply customization changes made before the first message (#332634) * agentHost: make customization scope acquisition infallible Removes the agent registration step from the active client service. A customization scope is now created on demand, so `acquireScope` always returns a scope and callers no longer handle an absent one. An absent scope silently degraded a session to no customizations at all. - Deletes `registerForAgent`, `IAgentRegistration` and `IAgentRegistrationOptions`. The service owns the scope map directly, keyed by session type and roots, and keeps the existing refcount. - Derives the user storage option from `isRemoteAgentHostSessionType` instead of passing it in. A host that does not share the client filesystem needs the user prompt files sent over the wire. - Adds `getSyncProvider`, owned by the service and cached per session type. Its identity must stay stable across scope churn because the customization harness keeps a reference to it. - Moves `getOrigin` to the service, which now scans all scopes. Synced URIs carry a per scope authority, so a global scan cannot alias. - Makes `activeClientScope` required on the draft session, the provisional session binding and the session handler entry, and removes the dead branches that handled the absent case. (Commit message generated by Copilot) * agentHost: republish customizations from a draft session A draft session read its customization scope one time, when it created the backend session, and never again. A customization the user changed before the first message did not reach the agent. This was most visible for a standalone MCP server: the client owns the global decision for a server it bundles, so a disable had no effect until the user sent a message. Started sessions were correct already, because the session handler reconciles them. - Adds a publisher on the draft session that watches the customization scope and sends `SessionActiveClientSet` when it changes. It is installed after `createSession` resolves, so the host always knows the session that the action refers to. - Holds back publication while the scope is unresolved. An unresolved scope would erase the customization state of the session. - Compares against the live session state and against the last value sent, and seeds the last value with the one that `createSession` carried. The first run is therefore silent when nothing changed. - Stops the publisher when the draft graduates, because the session handler owns reconciliation from that point. Fixes https://github.com/microsoft/vscode/issues/332257 Fixes https://github.com/microsoft/vscode/issues/332258 (Commit message generated by Copilot) * agentHost: read MCP servers and toggles from a draft session `getMcpServers` and `setCustomizationEnablement` found the backend session URI in the session cache only. A draft session is not in that cache until it graduates, so both failed for a draft: the first returned an empty list and the second sent nothing. The empty list removed the agent host row for every MCP server of a draft, so the customization view showed the VS Code actions instead. The workspace action of VS Code writes to a different scope than the one the agent host uses, which greyed the row while the agent kept the server. - Adds a lookup that reads the session cache first and falls back to the open draft sessions, in the same way as `getCustomAgents`. - Publishes the deterministic backend URI of the draft session, which is the URI that the host keys the session by. - Keeps all other conditions, so a session without state still returns an empty list and a toggle without a connection still does nothing. (Commit message generated by Copilot) --- .../browser/baseAgentHostSessionsProvider.ts | 99 ++++++--- .../localAgentHostSessionsProvider.test.ts | 161 ++++++++++++++- .../browser/remoteAgentHost.contribution.ts | 7 +- .../agentHost/agentHostActiveClientService.ts | 190 ++++++------------ .../agentHost/agentHostChatContribution.ts | 7 +- .../agentHost/agentHostSessionHandler.ts | 18 +- ...ntHostUntitledProvisionalSessionService.ts | 25 +-- .../agentHostChatContribution.test.ts | 26 +-- .../agentHostClientTools.test.ts | 37 ++-- 9 files changed, 344 insertions(+), 226 deletions(-) diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 16db9fab4c5..82e4944f178 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -28,7 +28,7 @@ import { KNOWN_MODE_VALUES, SessionConfigKey } from '../../../../../platform/age import { migrateLegacyAutopilotConfig } from '../../../../../platform/agentHost/common/agentHostSchema.js'; import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ResolveSessionConfigResult, type SessionConfigPropertySchema } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { AgentCustomization, ChangesSummary, ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, type ClientPluginCustomization, Customization, CustomizationEnablementKind, CustomizationType, type CustomizationEnablement, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionState, SessionSummary, type Changeset } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { AgentCustomization, ChangesSummary, ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, type ClientPluginCustomization, Customization, CustomizationEnablementKind, CustomizationType, type CustomizationEnablement, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, type SessionActiveClient, SessionState, SessionSummary, type Changeset } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isChatAction, isSessionAction, NotificationType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, DEFAULT_CHAT_ID, getSessionChatResource, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionCreationReference, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, ROOT_STATE_URI, SESSION_META_MULTI_ROOT_KEY, SessionMeta, SessionSourceControlOutcome, StateComponents, withSessionCreationReference, withSessionExternal, withSessionGitHubState, withSessionMultiRootMetadata, withSessionStatusFlag, withSessionWorkspaceless, type ChatState, type ChatSummary, type ISessionCreationReference as IProtocolSessionCreationReference, type ISessionGitHubState, type ISessionGitState, type ISessionMultiRootMetadata } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -1838,7 +1838,7 @@ interface INewSessionConstructionContext { * takes over ownership of the same `sessionId` key. */ readonly onSessionState?: (sessionId: string, state: SessionState | undefined) => void; - readonly activeClientScope?: IAgentCustomizationScope; + readonly activeClientScope: IAgentCustomizationScope; } /** @@ -1867,8 +1867,8 @@ class NewSession extends Disposable { readonly session: ISession; readonly sessionId: string; readonly agentProvider: string; - /** This draft's URI as the host's registry would key it. See {@link AgentHostSessionAdapter.backendUri}. */ - private readonly _backendSessionUri: URI; + /** This draft's URI as the host's registry is keyed by it. */ + readonly backendUri: URI; readonly workspaceUri: URI | undefined; readonly requiresWorkspaceTrust: boolean; /** `true` when this is a workspace-less quick chat. */ @@ -1904,7 +1904,7 @@ class NewSession extends Disposable { } getClientCustomAgents(): readonly AgentCustomization[] { - return this._activeClientScope?.customAgents.get() ?? []; + return this._activeClientScope.customAgents.get(); } /** @@ -1948,9 +1948,15 @@ class NewSession extends Disposable { * in {@link graduate} (handoff) and {@link dispose} (close-without-send). */ private readonly _stateListener = this._register(new MutableDisposable()); + /** + * Autorun republishing active-client changes for this draft. Cleared in + * {@link graduate} so the session handler's own reconciliation owns + * republishing from then on, and the two never race. + */ + private readonly _activeClientPublisher = this._register(new MutableDisposable()); private readonly _onSessionState: ((sessionId: string, state: SessionState | undefined) => void) | undefined; - private readonly _activeClientScope: IAgentCustomizationScope | undefined; + private readonly _activeClientScope: IAgentCustomizationScope; private readonly _initialMetadata: Record | undefined; private readonly _logService: ILogService; @@ -1975,16 +1981,14 @@ class NewSession extends Disposable { this._logService = ctx.logService; this._onSessionState = ctx.onSessionState; this._activeClientScope = ctx.activeClientScope; - if (this._activeClientScope) { - this._register(this._activeClientScope); - } + this._register(this._activeClientScope); this._initialMetadata = ctx.initialMetadata; const resource = URI.from({ scheme: ctx.resourceScheme, path: `/${generateUuid()}` }); this._isActiveSessionObs = derived(this, reader => isEqual(sessionsService.activeSession.read(reader)?.resource, resource)); // Defaults to scheme == provider; only hosts that address sessions under a different // scheme (cloud sandbox: provider `copilot`, scheme `ahp-session`) override it. - this._backendSessionUri = AgentSession.uri(ctx.backendSessionScheme ?? this.agentProvider, AgentSession.id(resource)); + this.backendUri = AgentSession.uri(ctx.backendSessionScheme ?? this.agentProvider, AgentSession.id(resource)); this._status = observableValue(this, SessionStatus.Untitled); this._title = observableValue(this, ''); const title = this._title; @@ -2277,7 +2281,7 @@ class NewSession extends Disposable { * no session state exists at send time. */ eagerCreate(connection: IAgentConnection, canCreate?: () => Promise): void { - const backendUri = this._backendSessionUri; + const backendUri = this.backendUri; if (this._eagerCreateTask || this._backendUri?.toString() === backendUri.toString() || this._subscription) { return; } @@ -2300,12 +2304,18 @@ class NewSession extends Disposable { this._backendUri = backendUri; this._connection = connection; + // Seeds the publisher below so its first run is a no-op when nothing + // changed, without depending on the state subscription having + // hydrated by then. + let createdWithActiveClient: SessionActiveClient | undefined; + try { - await this._activeClientScope?.whenResolved(); + await this._activeClientScope.whenResolved(); if (this._backendUri?.toString() !== backendUri.toString()) { return; } - const activeClient = this._activeClientScope?.activeClient(connection.clientId).get(); + const activeClient = this._activeClientScope.activeClient(connection.clientId).get(); + createdWithActiveClient = activeClient; await connection.createSession({ provider: this.agentProvider, session: backendUri, @@ -2319,7 +2329,7 @@ class NewSession extends Disposable { // `progress` frame so `_handleProgress` can correlate it. progressToken: generateUuid(), ...(this._selectedAgent ? { agent: { uri: this._selectedAgent.uri } } : {}), - ...(activeClient ? { activeClient } : {}), + activeClient, }); } catch (err) { this._logService.warn(`[${this._providerId}] Eager createSession failed for ${backendUri.toString()}: ${err}`); @@ -2365,6 +2375,31 @@ class NewSession extends Disposable { onSessionState(this.sessionId, state); }); } + + // Republishes this draft's contribution whenever the customization + // scope changes. Without it a client-owned decision made before the + // first send — notably disabling a standalone MCP server — would + // never reach the host, since `createSession` above only ever + // carried a one-shot snapshot. + let lastPublished: SessionActiveClient | undefined = createdWithActiveClient; + this._activeClientPublisher.value = autorun(reader => { + // Publishing an unresolved scope would transiently wipe the + // host's customization state for this session. + if (!this._activeClientScope.isResolved.read(reader)) { + return; + } + const activeClient = this._activeClientScope.activeClient(connection.clientId).read(reader); + const state = ref.object.value; + const existing = state instanceof Error ? undefined : state?.activeClients.find(client => client.clientId === activeClient.clientId); + if (equals(existing, activeClient) || equals(lastPublished, activeClient)) { + return; + } + lastPublished = activeClient; + connection.dispatch(backendUri.toString(), { + type: ActionType.SessionActiveClientSet, + activeClient, + }); + }); })(); } @@ -2379,7 +2414,7 @@ class NewSession extends Disposable { return; } - const changesets = createChangesets(this._backendSessionUri, this._options, this._isActiveSessionObs, changesetsMetadata); + const changesets = createChangesets(this.backendUri, this._options, this._isActiveSessionObs, changesetsMetadata); this._changesets.set(changesets, undefined); } @@ -2397,6 +2432,7 @@ class NewSession extends Disposable { // here hands ownership cleanly to `_ensureSessionStateSubscription` // without a transient empty-read window or a duplicate writer. this._stateListener.clear(); + this._activeClientPublisher.clear(); this._subscription?.dispose(); this._subscription = undefined; this._backendUri = undefined; @@ -2417,6 +2453,7 @@ class NewSession extends Disposable { // reached the post-`createSession` branch). const hadListener = !!this._stateListener.value; this._stateListener.clear(); + this._activeClientPublisher.clear(); if (hadListener) { this._onSessionState?.(this.sessionId, undefined); } @@ -2592,6 +2629,14 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return this._newSessions.get(sessionId); } + private _getBackendSessionUri(sessionId: string): URI | undefined { + const rawId = this._rawIdFromChatId(sessionId); + if (!rawId) { + return undefined; + } + return this._sessionCache.get(rawId)?.backendUri ?? this._newSessions.get(sessionId)?.backendUri; + } + /** * Dispose every in-flight new session, firing each one's `disposeSession` * sentinel so the eagerly-created backend records are freed. Used when the @@ -2986,11 +3031,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!scope || this._activeSessionScopeSessionType !== sessionType || !this._activeClientService.areScopeRootsEqual(this._activeSessionScopeRoots, cached.workingDirectories)) { scope = this._activeClientService.acquireScope(sessionType, cached.workingDirectories); this._activeSessionScope.value = scope; - this._activeSessionScopeSessionType = scope ? sessionType : undefined; - this._activeSessionScopeRoots = scope ? [...cached.workingDirectories] : undefined; - } - if (!scope) { - return; + this._activeSessionScopeSessionType = sessionType; + this._activeSessionScopeRoots = [...cached.workingDirectories]; } void this._dispatchActiveClientWhenResolved(cancellation.token, activeSession.sessionId, rawId, cached, connection, scope); @@ -3189,11 +3231,11 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement ...this._adapterOptions(), } satisfies IAgentHostAdapterOptions); } catch (err) { - activeClientScope?.dispose(); + activeClientScope.dispose(); throw err; } this._newSessions.set(newSession.sessionId, newSession); - newSession.observeClientCustomAgents(activeClientScope?.customAgents ?? constObservable([]), () => { + newSession.observeClientCustomAgents(activeClientScope.customAgents, () => { this._onDidChangeCustomAgents.fire(); this._onDidChangeCustomizations.fire(); }); @@ -4006,12 +4048,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!sessionState) { return []; } - const rawId = this._rawIdFromChatId(sessionId); - const cached = rawId ? this._sessionCache.get(rawId) : undefined; - if (!cached || !rawId) { + const sessionUri = this._getBackendSessionUri(sessionId); + if (!sessionUri) { return []; } - const sessionUri = cached.backendUri; return (sessionState.customizations ?? []) .flatMap(customization => customization.type === CustomizationType.McpServer ? [{ server: customization, plugin: undefined }] @@ -4064,13 +4104,12 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } setCustomizationEnablement(sessionId: string, customizationId: string, enablement: readonly CustomizationEnablement[]): void { - const rawId = this._rawIdFromChatId(sessionId); - const cached = rawId ? this._sessionCache.get(rawId) : undefined; + const sessionUri = this._getBackendSessionUri(sessionId); const connection = this.connection; - if (!cached || !connection) { + if (!sessionUri || !connection) { return; } - connection.dispatch(cached.backendUri.toString(), { + connection.dispatch(sessionUri.toString(), { type: ActionType.SessionCustomizationToggled, id: customizationId, enablement: [...enablement], diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 19c5c4995a6..0faaa861833 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -9,7 +9,7 @@ import { DeferredPromise, raceTimeout, timeout } from '../../../../../../base/co import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableMap, DisposableStore, ImmortalReference, toDisposable, type IReference } from '../../../../../../base/common/lifecycle.js'; -import { autorun, constObservable, ISettableObservable, observableFromEvent, observableValue, type IObservable } from '../../../../../../base/common/observable.js'; +import { autorun, constObservable, derived, ISettableObservable, observableFromEvent, observableValue, type IObservable } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { isEqual } from '../../../../../../base/common/resources.js'; import { mock } from '../../../../../../base/test/common/mock.js'; @@ -422,7 +422,7 @@ function createSchemaDefaultConfigurationService(): TestConfigurationService { function createProvider(disposables: DisposableStore, agentHostService: MockAgentHostService, contributions = [ { type: 'agent-host-copilotcli', name: 'copilot', displayName: 'Copilot', description: 'test', icon: undefined }, -], options?: { sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; acquireOrLoadSession?: (resource: URI) => Promise; languageModelIds?: string[]; lookupLanguageModel?: (modelId: string) => ILanguageModelChatMetadata | undefined; hiddenLanguageModelIds?: ReadonlySet; languageModelVisibilityChanges?: Event; openSession?: boolean; configurationService?: IConfigurationService; activeSession?: IObservable; visibleSessions?: IObservable; activeClient?: Omit; activeClientAgents?: IObservable; activeClientScope?: (sessionType: string, roots: readonly URI[]) => IAgentCustomizationScope | undefined; storageService?: IStorageService; isSessionsWindow?: boolean; confirmDelete?: boolean; workspaceTrusted?: boolean; workspaceTrustBarrier?: DeferredPromise; workspaceTrustError?: Error; gitHubService?: IGitHubService }): LocalAgentHostSessionsProvider { +], options?: { sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; acquireOrLoadSession?: (resource: URI) => Promise; languageModelIds?: string[]; lookupLanguageModel?: (modelId: string) => ILanguageModelChatMetadata | undefined; hiddenLanguageModelIds?: ReadonlySet; languageModelVisibilityChanges?: Event; openSession?: boolean; configurationService?: IConfigurationService; activeSession?: IObservable; visibleSessions?: IObservable; activeClient?: Omit; activeClientAgents?: IObservable; activeClientScope?: (sessionType: string, roots: readonly URI[]) => IAgentCustomizationScope; storageService?: IStorageService; isSessionsWindow?: boolean; confirmDelete?: boolean; workspaceTrusted?: boolean; workspaceTrustBarrier?: DeferredPromise; workspaceTrustError?: Error; gitHubService?: IGitHubService }): LocalAgentHostSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IAgentHostService, agentHostService); @@ -2681,6 +2681,163 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); + test('createNewSession republishes standalone MCP enablement after eager creation', async () => { + const customizations = observableValue>('draftActiveClientCustomizations', [{ + type: CustomizationType.Plugin, + id: 'vscode://synced-data', + uri: 'vscode://synced-data', + name: 'VS Code Synced Data', + childEnablement: { + 'docs-server': [{ kind: CustomizationEnablementKind.Global, enabled: true }], + }, + }]); + const customAgents = observableValue('draftActiveClientAgents', []); + const tools = observableValue('draftActiveClientTools', []); + const isResolved = observableValue('draftActiveClientResolved', true); + const scope: IAgentCustomizationScope = { + customizations, + customAgents, + tools, + isResolved, + whenResolved: () => Promise.resolve(), + activeClient: clientId => derived(reader => { + customAgents.read(reader); + return { + clientId, + customizations: customizations.read(reader), + tools: tools.read(reader), + }; + }), + dispose: () => { }, + }; + const provider = createProvider(disposables, agentHost, undefined, { activeClientScope: () => scope }); + agentHost.onCreateSession = uri => { + agentHost.setSessionState(AgentSession.id(uri), AgentSession.provider(uri)!, { + provider: AgentSession.provider(uri)!, + title: '', + status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [{ + clientId: agentHost.clientId, + customizations: customizations.get(), + tools: tools.get(), + }], + chats: [], + }); + }; + + const session = provider.createNewSession(URI.parse('file:///home/user/my-project'), provider.sessionTypes[0].id); + await timeout(0); + const dispatchCount = agentHost.dispatchedActions.filter(dispatch => dispatch.action.type === ActionType.SessionActiveClientSet).length; + const disabledCustomizations = [{ + type: CustomizationType.Plugin, + id: 'vscode://synced-data', + uri: 'vscode://synced-data', + name: 'VS Code Synced Data', + childEnablement: { + 'docs-server': [{ kind: CustomizationEnablementKind.Global, enabled: false }], + }, + }] satisfies NonNullable; + customizations.set(disabledCustomizations, undefined); + + const activeClientDispatches = agentHost.dispatchedActions.filter(dispatch => dispatch.action.type === ActionType.SessionActiveClientSet); + assert.deepStrictEqual( + { + initialDispatchCount: dispatchCount, + actions: activeClientDispatches + .slice(dispatchCount) + .map(({ channel, action }) => ({ channel, action })), + }, + { + initialDispatchCount: 0, + actions: [{ + channel: AgentSession.uri(provider.sessionTypes[0].id, session.resource.path.substring(1)).toString(), + action: { + type: ActionType.SessionActiveClientSet, + activeClient: { + clientId: agentHost.clientId, + customizations: disabledCustomizations, + tools: [], + }, + }, + }], + }, + ); + }); + + test('getMcpServers returns MCP servers from a draft session', async () => { + const provider = createProvider(disposables, agentHost); + agentHost.onCreateSession = uri => { + agentHost.setSessionState(AgentSession.id(uri), AgentSession.provider(uri)!, { + provider: AgentSession.provider(uri)!, + title: '', + status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + chats: [], + customizations: [{ + type: CustomizationType.Plugin, + id: 'vscode://synced-data', + uri: 'vscode://synced-data', + name: 'VS Code Synced Data', + children: [{ + type: CustomizationType.McpServer, + id: 'docs-server', + uri: 'vscode://synced-data/docs-server', + name: 'Docs Server', + state: { kind: McpServerStatus.Ready }, + }], + }], + }); + }; + + const session = provider.createNewSession(URI.parse('file:///home/user/my-project'), provider.sessionTypes[0].id); + await timeout(0); + + assert.deepStrictEqual(provider.getMcpServers(session.sessionId).map(server => ({ + id: server.id, + name: server.name, + enabled: server.enabled, + status: server.status, + state: server.state, + })), [{ + id: `${AgentSession.uri(provider.sessionTypes[0].id, session.resource.path.substring(1)).authority}/docs-server`, + name: 'Docs Server', + enabled: true, + status: McpServerStatus.Ready, + state: { kind: McpServerStatus.Ready }, + }]); + }); + + test('setCustomizationEnablement dispatches for a draft session', async () => { + const provider = createProvider(disposables, agentHost); + agentHost.onCreateSession = uri => { + agentHost.setSessionState(AgentSession.id(uri), AgentSession.provider(uri)!, { + provider: AgentSession.provider(uri)!, + title: '', + status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + chats: [], + }); + }; + + const session = provider.createNewSession(URI.parse('file:///home/user/my-project'), provider.sessionTypes[0].id); + await timeout(0); + agentHost.dispatchedActions.length = 0; + const enablement = [{ kind: CustomizationEnablementKind.Workspace, uri: 'file:///home/user/my-project', enabled: false }]; + provider.setCustomizationEnablement(session.sessionId, 'docs-server', enablement); + + assert.deepStrictEqual(agentHost.dispatchedActions.map(({ channel, action }) => ({ channel, action })), [{ + channel: AgentSession.uri(provider.sessionTypes[0].id, session.resource.path.substring(1)).toString(), + action: { + type: ActionType.SessionCustomizationToggled, + id: 'docs-server', + enablement, + }, + }]); + }); + // ---- Quick chats (workspace-less sessions) ------- test('declares quick chat support', () => { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts index de9a269bef1..3579ae477fd 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -976,10 +976,9 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc connection, )); - const agentRegistration = agentStore.add(this._activeClientService.registerForAgent(sessionType, { includeUserStorage: true })); - const syncProvider = agentRegistration.syncProvider; + const syncProvider = this._activeClientService.getSyncProvider(sessionType); // The management UI remains ambient while individual sessions use their working-directory scopes. - const ambientScope = agentStore.add(agentRegistration.acquireScope([])); + const ambientScope = agentStore.add(this._activeClientService.acquireScope(sessionType, [])); const itemProvider = agentStore.add(this._instantiationService.createInstance(AgentCustomizationItemProvider, sanitized, @@ -995,7 +994,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc run: () => pluginController.removeConfiguredPlugin(customization), }]; }, - syncedUri => agentRegistration.getOrigin(syncedUri) + syncedUri => this._activeClientService.getOrigin(syncedUri) )); itemProvider.setDraftCustomAgents(ambientScope.customAgents); itemProvider.setDraftCustomizations(ambientScope.customizations); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts index 1a498bbc7a4..b9aeedfe2fa 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts @@ -14,6 +14,7 @@ import { equals } from '../../../../../../base/common/objects.js'; import { autorun, derived, IObservable, observableValue, transaction } from '../../../../../../base/common/observable.js'; import { type IExtUri } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { isRemoteAgentHostSessionType } from '../../../../../../platform/agentHost/common/agentHostSessionType.js'; import type { AgentCustomization, SessionActiveClient, ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import type { ClientPluginCustomization } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { CLIENT_SEMANTIC_SEARCH_REFERENCE_NAME, CLIENT_SEMANTIC_SEARCH_TOOL_ID, CopilotSemanticSearchEnabledSettingId, SEMANTIC_SEARCH_TOOL_NAME } from '../../../../../../platform/agentHost/common/semanticSearchConstants.js'; @@ -24,7 +25,7 @@ import { createDecorator, IInstantiationService } from '../../../../../../platfo import { observableConfigValue } from '../../../../../../platform/observable/common/platformObservableUtils.js'; import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; import { IUriIdentityService } from '../../../../../../platform/uriIdentity/common/uriIdentity.js'; -import { ICustomizationSyncProvider } from '../../../common/customizationHarnessService.js'; +import type { ICustomizationSyncProvider } from '../../../common/customizationHarnessService.js'; import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; import { ILanguageModelToolsService, IToolData, IToolSet } from '../../../common/tools/languageModelToolsService.js'; @@ -35,6 +36,7 @@ import { type ILocalCustomizationSyncOptions, resolveCustomizationRefs, resolveL import { toolDataToDefinition } from './agentHostToolUtils.js'; import { IAgentHostToolSetEnablementService, isCopilotCliSessionType, isToolEnabledInSet } from './agentHostToolSetEnablementService.js'; import { type ISyncedCustomizationOrigin, SyncedCustomizationBundler } from './syncedCustomizationBundler.js'; +import { Iterable } from '../../../../../../base/common/iterator.js'; export const IAgentHostActiveClientService = createDecorator('agentHostActiveClientService'); @@ -55,108 +57,18 @@ export interface IAgentCustomizationScope extends IDisposable { whenResolved(): Promise; } -/** Registration-level customization state for an agent harness. */ -export interface IAgentRegistration extends IDisposable { - readonly syncProvider: ICustomizationSyncProvider; - /** Acquires (or shares) the scope for `roots`. Refcounted: torn down when the last holder disposes. */ - acquireScope(roots: readonly URI[]): IAgentCustomizationScope; - /** Recovers provenance for a synced URI produced by any scope of this agent. */ - getOrigin(syncedUri: URI): ISyncedCustomizationOrigin | undefined; - isBundledMcpServer(pluginUri: string, serverName: string): boolean; -} - -export type IAgentRegistrationOptions = ILocalCustomizationSyncOptions; - export interface IAgentHostActiveClientService { readonly _serviceBrand: undefined; - - /** Registers an agent harness and its registration-level customization sync provider. */ - registerForAgent(sessionType: string, options?: IAgentRegistrationOptions): IAgentRegistration; - - /** Acquires a customization scope for a registered agent. Returns `undefined` when `sessionType` has no registration. */ - acquireScope(sessionType: string, roots: readonly URI[]): IAgentCustomizationScope | undefined; + /** Acquires (or shares) the refcounted customization scope for `sessionType` + `roots`. Never fails. */ + acquireScope(sessionType: string, roots: readonly URI[]): IAgentCustomizationScope; + /** The persisted customization sync provider for `sessionType`. */ + getSyncProvider(sessionType: string): ICustomizationSyncProvider; + /** Recovers provenance for a synced URI produced by any scope. */ + getOrigin(syncedUri: URI): ISyncedCustomizationOrigin | undefined; areScopeRootsEqual(first: readonly URI[] | undefined, second: readonly URI[]): boolean; isBundledMcpServer(pluginUri: string, serverName: string): boolean; } -class AgentRegistration extends Disposable implements IAgentRegistration { - - readonly syncProvider: ICustomizationSyncProvider; - - private readonly _scopes = new Map(); - private _isDisposed = false; - - constructor( - private readonly _sessionType: string, - private readonly _options: IAgentRegistrationOptions | undefined, - private readonly _instantiationService: IInstantiationService, - storageService: IStorageService, - private readonly _extUri: IExtUri, - private readonly _getClientTools: (sessionType: string) => IObservable, - private readonly _onDispose: () => void, - ) { - super(); - this.syncProvider = this._register(new AgentCustomizationSyncProvider(_sessionType, storageService)); - } - - acquireScope(roots: readonly URI[]): IAgentCustomizationScope { - const normalizedRoots = normalizeRoots(roots, this._extUri); - const scopeKey = getScopeKey(normalizedRoots, this._extUri); - let scope = this._scopes.get(scopeKey); - if (!scope) { - // Referenced by the teardown callback below, which only runs once the - // scope has been constructed. - const createdScope: AgentCustomizationScope = this._instantiationService.createInstance( - AgentCustomizationScope, - this._sessionType, - normalizedRoots, - scopeKey, - this.syncProvider, - this._options, - this._getClientTools, - () => this._removeScope(scopeKey, createdScope), - ); - scope = createdScope; - this._scopes.set(scopeKey, scope); - } - return scope.acquire(); - } - - getOrigin(syncedUri: URI): ISyncedCustomizationOrigin | undefined { - for (const scope of this._scopes.values()) { - const origin = scope.getOrigin(syncedUri); - if (origin) { - return origin; - } - } - return undefined; - } - - isBundledMcpServer(pluginUri: string, serverName: string): boolean { - return [...this._scopes.values()].some(scope => scope.isBundledMcpServer(pluginUri, serverName)); - } - - override dispose(): void { - if (this._isDisposed) { - return; - } - this._isDisposed = true; - const scopes = [...this._scopes.values()]; - this._scopes.clear(); - for (const scope of scopes) { - scope.dispose(); - } - super.dispose(); - this._onDispose(); - } - - private _removeScope(scopeKey: string, scope: AgentCustomizationScope): void { - if (this._scopes.get(scopeKey) === scope) { - this._scopes.delete(scopeKey); - } - } -} - /** Owns the customization bundle and resolution lifecycle for one working-directory scope. */ class AgentCustomizationScope extends Disposable { @@ -192,7 +104,7 @@ class AgentCustomizationScope extends Disposable { private readonly _roots: readonly URI[], scopeKey: string, private readonly _syncProvider: ICustomizationSyncProvider, - private readonly _options: IAgentRegistrationOptions | undefined, + private readonly _options: ILocalCustomizationSyncOptions | undefined, private readonly _getClientTools: (sessionType: string) => IObservable, private readonly _onDispose: () => void, @IFileService private readonly _fileService: IFileService, @@ -354,7 +266,8 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo private readonly _allToolSetsObs: IObservable>; private readonly _semanticSearchEnabled: IObservable; private readonly _clientToolsByType = new Map>(); - private readonly _registrationsByType = new Map(); + private readonly _scopes = new Map(); + private readonly _syncProviders = new Map(); private _isDisposed = false; constructor( @@ -371,28 +284,47 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo this._semanticSearchEnabled = observableConfigValue(CopilotSemanticSearchEnabledSettingId, false, configurationService); } - registerForAgent(sessionType: string, options?: IAgentRegistrationOptions): IAgentRegistration { - // Referenced by the teardown callback below, which only runs once the - // registration has been constructed. - const registration: AgentRegistration = new AgentRegistration( - sessionType, - options, - this._instantiationService, - this._storageService, - this._uriIdentityService.extUri, - type => this._getClientTools(type), - () => { - if (this._registrationsByType.get(sessionType) === registration) { - this._registrationsByType.delete(sessionType); - } - }, - ); - this._registrationsByType.set(sessionType, registration); - return registration; + acquireScope(sessionType: string, roots: readonly URI[]): IAgentCustomizationScope { + const normalizedRoots = normalizeRoots(roots, this._uriIdentityService.extUri); + const scopeKey = getScopeKey(normalizedRoots, this._uriIdentityService.extUri); + const serviceScopeKey = getServiceScopeKey(sessionType, scopeKey); + let scope = this._scopes.get(serviceScopeKey); + if (!scope) { + // A host that does not share the client's filesystem needs user storage shipped over the wire. + const options = isRemoteAgentHostSessionType(sessionType) ? { includeUserStorage: true } : undefined; + const createdScope: AgentCustomizationScope = this._instantiationService.createInstance( + AgentCustomizationScope, + sessionType, + normalizedRoots, + scopeKey, + this.getSyncProvider(sessionType), + options, + type => this._getClientTools(type), + () => this._removeScope(serviceScopeKey, createdScope), + ); + scope = createdScope; + this._scopes.set(serviceScopeKey, scope); + } + return scope.acquire(); } - acquireScope(sessionType: string, roots: readonly URI[]): IAgentCustomizationScope | undefined { - return this._registrationsByType.get(sessionType)?.acquireScope(roots); + getSyncProvider(sessionType: string): ICustomizationSyncProvider { + let syncProvider = this._syncProviders.get(sessionType); + if (!syncProvider) { + syncProvider = this._register(new AgentCustomizationSyncProvider(sessionType, this._storageService)); + this._syncProviders.set(sessionType, syncProvider); + } + return syncProvider; + } + + getOrigin(syncedUri: URI): ISyncedCustomizationOrigin | undefined { + for (const scope of this._scopes.values()) { + const origin = scope.getOrigin(syncedUri); + if (origin) { + return origin; + } + } + return undefined; } areScopeRootsEqual(first: readonly URI[] | undefined, second: readonly URI[]): boolean { @@ -400,7 +332,7 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo } isBundledMcpServer(pluginUri: string, serverName: string): boolean { - return [...this._registrationsByType.values()].some(registration => registration.isBundledMcpServer(pluginUri, serverName)); + return Iterable.some([...this._scopes.values()], scope => scope.isBundledMcpServer(pluginUri, serverName)); } private _getClientTools(sessionType: string): IObservable { @@ -454,13 +386,19 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo return; } this._isDisposed = true; - const registrations = [...this._registrationsByType.values()]; - this._registrationsByType.clear(); - for (const registration of registrations) { - registration.dispose(); + const scopes = [...this._scopes.values()]; + this._scopes.clear(); + for (const scope of scopes) { + scope.dispose(); } super.dispose(); } + + private _removeScope(scopeKey: string, scope: AgentCustomizationScope): void { + if (this._scopes.get(scopeKey) === scope) { + this._scopes.delete(scopeKey); + } + } } function normalizeRoots(roots: readonly URI[], extUri: IExtUri): readonly URI[] { @@ -489,6 +427,10 @@ function getScopeKey(roots: readonly URI[], extUri: IExtUri): string { return roots.map(root => extUri.getComparisonKey(root)).join('\n'); } +function getServiceScopeKey(sessionType: string, scopeKey: string): string { + return JSON.stringify([sessionType, scopeKey]); +} + function createScopeAuthority(sessionType: string, scopeKey: string): string { return `${sessionType}-${hash(scopeKey)}`; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index 3a85f5c12b8..ebf8c836419 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -312,13 +312,12 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr }, })); - const agentRegistration = store.add(this._activeClientService.registerForAgent(sessionType)); - const syncProvider = agentRegistration.syncProvider; + const syncProvider = this._activeClientService.getSyncProvider(sessionType); // The management UI remains ambient while individual sessions use their working-directory scopes. - const ambientScope = store.add(agentRegistration.acquireScope([])); + const ambientScope = store.add(this._activeClientService.acquireScope(sessionType, [])); const itemProvider = store.add(this._instantiationService.createInstance(AgentCustomizationItemProvider, 'local', undefined, - syncedUri => agentRegistration.getOrigin(syncedUri))); + syncedUri => this._activeClientService.getOrigin(syncedUri))); itemProvider.setDraftCustomAgents(ambientScope.customAgents); itemProvider.setDraftCustomizations(ambientScope.customizations); // `[Agent Host]` suffix disambiguates from the extension-host Copilot CLI harness, which uses the same displayName. diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 8ac5a64784f..9bb962ccc26 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -2165,26 +2165,19 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return { ...activeClient, customizations: [] }; } - private _ensureActiveClient(sessionResource: URI, backendSession: URI): ActiveClientEntry | undefined { + private _ensureActiveClient(sessionResource: URI, backendSession: URI): ActiveClientEntry { const entry = this._ensureActiveClientEntry(sessionResource); - if (!entry) { - return undefined; - } entry.claim(backendSession); return entry; } - private _ensureActiveClientEntry(sessionResource: URI): ActiveClientEntry | undefined { + private _ensureActiveClientEntry(sessionResource: URI): ActiveClientEntry { const existing = this._activeClientEntries.get(sessionResource); if (existing) { return existing; } const scope = this._activeClientService.acquireScope(this._config.sessionType, this._resolveCustomizationScopeRoots(sessionResource)); - if (!scope) { - return undefined; - } - const entry = new ActiveClientEntry( scope, this._config.connection.clientId, @@ -2201,9 +2194,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC private _configureActiveClientReconciliation(sessionResource: URI, backendSession: URI, sessionSubscription: IAgentSubscription | undefined): void { const entry = this._ensureActiveClientEntry(sessionResource); - if (!entry) { - return; - } entry.attach(backendSession, sessionSubscription); } @@ -5116,9 +5106,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const protectedResources = await this._ensureRequiredAuthentication(model); const activeClientEntry = this._ensureActiveClientEntry(sessionResource); - if (activeClientEntry) { - await activeClientEntry.whenSettled(); - } + await activeClientEntry.whenSettled(); const activeClient = this._getCurrentActiveClient(sessionResource); // Opt in to bring-up progress (chiefly the lazy first-use SDK download) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts index f0563c4e9e5..1bdf457ab68 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts @@ -206,21 +206,19 @@ type ProvisionalOperationResult = URI | void; class ActiveClientBinding extends Disposable { constructor( readonly roots: readonly URI[], - readonly scope: IAgentCustomizationScope | undefined, + readonly scope: IAgentCustomizationScope, clientId: string, publish: () => void, ) { super(); - if (scope) { - this._register(scope); - this._register(autorun(reader => { - if (!scope.isResolved.read(reader)) { - return; - } - scope.activeClient(clientId).read(reader); - publish(); - })); - } + this._register(scope); + this._register(autorun(reader => { + if (!scope.isResolved.read(reader)) { + return; + } + scope.activeClient(clientId).read(reader); + publish(); + })); } } @@ -546,13 +544,10 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple return; } const scope = entry.activeClientBinding.value?.scope; - if (!scope?.isResolved.get()) { + if (!scope || !scope.isResolved.get()) { return; } const activeClient = scope.activeClient(this._agentHostService.clientId).get(); - if (!activeClient) { - return; - } this._agentHostService.dispatch(entry.generation.backendSession.toString(), { type: ActionType.SessionActiveClientSet, activeClient, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index f96258b8294..d2d92a352ac 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -984,27 +984,15 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv dispose: () => { }, }; }; + const syncProvider = { + onDidChange: Event.None, + isDisabled: () => false, + setDisabled: () => { }, + }; const activeClientService: IAgentHostActiveClientService = { _serviceBrand: undefined, - registerForAgent: (sessionType) => { - // Tests that exercise customization changes seed entries via - // `seedActiveClient` directly. This stub just records an empty - // entry so the contribution flow completes. - const inner = seedActiveClient(sessionType, { - customizations: constObservable([]), - }); - return { - syncProvider: { - onDidChange: Event.None, - isDisabled: () => false, - setDisabled: () => { }, - }, - acquireScope: roots => acquireScope(sessionType, roots), - getOrigin: () => undefined, - isBundledMcpServer: () => false, - dispose: () => inner.dispose(), - }; - }, + getSyncProvider: () => syncProvider, + getOrigin: () => undefined, acquireScope, areScopeRootsEqual: (first, second) => JSON.stringify(first) === JSON.stringify(second), isBundledMcpServer: () => false, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts index 40e7833d3b9..53ccc8c90af 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -145,18 +145,18 @@ suite('AgentHostClientTools', () => { }; } - test('shares a customization scope for equivalent root sets', async () => { + test('lazily creates scopes and shares them for equivalent root sets', async () => { const { service } = createActiveClientService(); - const registration = disposables.add(service.registerForAgent('agent-host-claude')); const rootA = URI.file('/Workspace-A'); const rootB = URI.file('/Workspace-B'); - const unregisteredScope = service.acquireScope('unregistered-agent', []); - const unresolvedScope = registration.acquireScope([URI.file('/unresolved-workspace')]); + const unregisteredScope = disposables.add(service.acquireScope('unregistered-agent', [])); + await unregisteredScope.whenResolved(); + const unresolvedScope = service.acquireScope('agent-host-claude', [URI.file('/unresolved-workspace')]); const unresolved = unresolvedScope.whenResolved(); unresolvedScope.dispose(); assert.strictEqual(await unresolved, undefined); - const first = registration.acquireScope([rootB, rootA, rootA]); - const second = registration.acquireScope([rootA, rootB]); + const first = service.acquireScope('agent-host-claude', [rootB, rootA, rootA]); + const second = service.acquireScope('agent-host-claude', [rootA, rootB]); await first.whenResolved(); const sharedScopeState = { @@ -165,19 +165,24 @@ suite('AgentHostClientTools', () => { }; first.dispose(); second.dispose(); - registration.dispose(); + const syncProvider = service.getSyncProvider('agent-host-claude'); + const scopeAfterRelease = service.acquireScope('agent-host-claude', []); + await scopeAfterRelease.whenResolved(); + scopeAfterRelease.dispose(); assert.deepStrictEqual({ - unregisteredScope, + unregisteredScopeIsResolved: unregisteredScope.isResolved.get(), sharedScopeState, - scopeAfterRegistrationDisposal: service.acquireScope('agent-host-claude', []), + syncProviderIsStable: syncProvider === service.getSyncProvider('agent-host-claude'), + scopeAfterReleaseIsResolved: scopeAfterRelease.isResolved.get(), }, { - unregisteredScope: undefined, + unregisteredScopeIsResolved: true, sharedScopeState: { customizations: true, customAgents: true, }, - scopeAfterRegistrationDisposal: undefined, + syncProviderIsStable: true, + scopeAfterReleaseIsResolved: true, }); }); @@ -221,8 +226,7 @@ suite('AgentHostClientTools', () => { override getTools(): Iterable { return tools.filter(tool => tool.id !== CLIENT_SEMANTIC_SEARCH_TOOL_ID); } }; const client = createActiveClientService(constObservable(tools), constObservable([searchToolSet, enabledToolSet])); - const registration = disposables.add(client.service.registerForAgent(sessionType)); - const scope = disposables.add(registration.acquireScope([])); + const scope = disposables.add(client.service.acquireScope(sessionType, [])); await scope.whenResolved(); client.setSemanticSearchEnabled(enabled); return scope.tools.get().map(tool => [tool.name, tool.title]); @@ -779,6 +783,12 @@ suite('AgentHostClientTools', () => { instantiationService.stub(IAgentPluginService, { plugins: observableValue('plugins', []), }); + // Acquiring a customization scope is now infallible, so the handler + // constructs a real one — which reads these on its first autorun. + instantiationService.stub(IMcpService, { + servers: observableValue('mcpServers', []), + }); + instantiationService.stub(IConfigurationResolverService, {} as Partial); instantiationService.stub(IPromptsService, new class extends mock() { override readonly onDidChangeCustomAgents = Event.None; override readonly onDidChangeSlashCommands = Event.None; @@ -786,6 +796,7 @@ suite('AgentHostClientTools', () => { override readonly onDidChangeInstructions = Event.None; override readonly onDidChangeAgentInstructions = Event.None; + override getDisabledPromptFiles() { return new ResourceSet(); } override async listPromptFilesForStorage() { return []; } From 28c109e20bfd7fae68ee0d36c2ea6f2058ac6e64 Mon Sep 17 00:00:00 2001 From: roblourens Date: Tue, 25 Aug 2026 15:17:42 -0700 Subject: [PATCH 021/116] agent host: adopt durable error response parts (#332606) * agent host: adopt durable error response parts Sync the generated AHP types to b4016c0e and adapt existing error producers, restored sessions, UI consumers, telemetry, and tests to the durable response-part shape. This intentionally does not expose turn resume behavior.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agent host: update reducer turn snapshot Remove the obsolete Turn.error expectation after adopting durable error response parts.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agent host: preserve protocol error compatibility Normalize legacy live and restored error shapes before reduction, and reject turn resume until provider support exists. This preserves behavior with negotiated older hosts while keeping resume disabled.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentHostProtocolClient.ts | 5 +- .../common/state/agentSubscription.ts | 5 ++ .../state/legacyProtocolCompatibility.ts | 87 +++++++++++++++++++ .../common/state/protocol/.ahp-version | 2 +- .../state/protocol/action-origin.generated.ts | 5 +- .../protocol/channels-automation-run/state.ts | 2 + .../protocol/channels-automation/state.ts | 3 + .../protocol/channels-changeset/commands.ts | 1 + .../protocol/channels-changeset/state.ts | 3 + .../state/protocol/channels-chat/actions.ts | 37 ++++++-- .../state/protocol/channels-chat/commands.ts | 1 + .../state/protocol/channels-chat/reducer.ts | 50 ++++++++++- .../state/protocol/channels-chat/state.ts | 52 ++++++++++- .../channels-resource-watch/commands.ts | 2 +- .../protocol/channels-resource-watch/state.ts | 1 + .../state/protocol/channels-root/state.ts | 1 + .../protocol/channels-session/commands.ts | 1 + .../state/protocol/channels-session/state.ts | 16 +++- .../state/protocol/channels-terminal/state.ts | 2 + .../common/state/protocol/common/actions.ts | 5 +- .../common/state/protocol/common/commands.ts | 10 ++- .../common/state/protocol/common/errors.ts | 8 +- .../state/protocol/common/notifications.ts | 1 + .../common/state/protocol/version/registry.ts | 1 + .../agentHost/common/state/sessionState.ts | 13 ++- .../agentHost/node/agentSideEffects.ts | 10 +-- .../queueDrain/queueDrainContribution.ts | 4 +- .../node/claude/claudeMapSessionEvents.ts | 9 +- .../agentHost/node/codex/codexAgent.ts | 17 ++-- .../node/codex/codexMapAppServerEvents.ts | 2 +- .../agentHost/node/codex/codexReplayMapper.ts | 4 +- .../node/copilot/copilotAgentSession.ts | 4 +- .../node/copilot/mapSessionEvents.ts | 5 +- .../agentHost/node/protocolServerHandler.ts | 5 +- .../test/common/agentSubscription.test.ts | 82 ++++++++++++++++- .../test/node/agentHostStateManager.test.ts | 2 +- .../test/node/agentHostTurnTelemetry.test.ts | 27 +++--- .../test/node/agentSideEffects.test.ts | 12 +-- .../test/node/chatContributions.test.ts | 6 +- .../test/node/claudeMapSessionEvents.test.ts | 4 +- .../codex/codexMapAppServerEvents.test.ts | 2 +- .../test/node/codex/codexReplayMapper.test.ts | 4 +- .../test/node/copilotAgentSession.test.ts | 31 ++++--- .../e2e/harness/agentHostE2ETestHarness.ts | 2 +- .../test/node/e2e/harness/ahpSnapshot.ts | 8 +- .../copilotAgentHostE2E.integrationTest.ts | 8 +- .../node/e2e/suites/copilotCoverageSuite.ts | 2 +- .../test/node/e2e/suites/coreSuite.ts | 10 ++- .../test/node/e2e/suites/multiChatSuite.ts | 2 +- .../test/node/mapSessionEvents.test.ts | 10 ++- .../platform/agentHost/test/node/mockAgent.ts | 2 +- .../protocol/turnExecution.integrationTest.ts | 2 +- .../test/node/protocolServerHandler.test.ts | 25 +++--- .../agentHost/test/node/reducers.test.ts | 1 - .../agentHost/agentHostSessionHandler.ts | 33 ++++--- .../importLocalConversationToAgentSession.ts | 6 +- .../agentHost/stateToProgressAdapter.ts | 9 +- .../agentHostChatContribution.test.ts | 10 ++- ...ortLocalConversationToAgentSession.test.ts | 6 +- .../stateToProgressAdapter.test.ts | 15 ++-- 60 files changed, 533 insertions(+), 162 deletions(-) create mode 100644 src/vs/platform/agentHost/common/state/legacyProtocolCompatibility.ts diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index 762d965b188..117f7735167 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -27,6 +27,7 @@ import { AgentHostResourceIdentity, AgentHostResourcePermissionError, IAgentHost import type { ClientNotificationMap, CommandMap, JsonRpcErrorResponse, JsonRpcRequest } from '../common/state/protocol/messages.js'; import { ActionType, type ActionEnvelope, type ChatAction, type ClientAnnotationsAction, type ClientChangesetAction, type INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; import { MessageAttachmentKind, SessionSummary, ROOT_STATE_URI, StateComponents, isAhpRootChannel, type ClientPluginCustomization, type Message, type RootState } from '../common/state/sessionState.js'; +import { normalizeLegacyActionEnvelope } from '../common/state/legacyProtocolCompatibility.js'; import { SUPPORTED_PROTOCOL_VERSIONS } from '../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, ProtocolError, ReconnectResultType, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { type IVscodeUpgradeResult } from '../common/state/protocolUpgrade.js'; @@ -862,7 +863,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect if (envelope.serverSeq > maxSeq) { maxSeq = envelope.serverSeq; } - this._onDidAction.fire(envelope); + this._onDidAction.fire(normalizeLegacyActionEnvelope(envelope)); } this._serverSeq = maxSeq; if (result.missing.length > 0) { @@ -1471,7 +1472,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect // Protocol envelope → VS Code envelope (superset of action types) const envelope = msg.params; this._serverSeq = Math.max(this._serverSeq, envelope.serverSeq); - this._onDidAction.fire(envelope); + this._onDidAction.fire(normalizeLegacyActionEnvelope(envelope)); break; } case 'root/sessionAdded': diff --git a/src/vs/platform/agentHost/common/state/agentSubscription.ts b/src/vs/platform/agentHost/common/state/agentSubscription.ts index 206b489c8c5..53a15afeca0 100644 --- a/src/vs/platform/agentHost/common/state/agentSubscription.ts +++ b/src/vs/platform/agentHost/common/state/agentSubscription.ts @@ -16,6 +16,7 @@ import type { RootAction, SessionAction as IProtocolSessionAction, ChatAction as import type { AnnotationsState, ChangesetState, ChatState, RootState, SessionState, TerminalState } from './protocol/state.js'; import type { IStateSnapshot } from './sessionProtocol.js'; import { isAhpRootChannel, ROOT_STATE_URI, StateComponents } from './sessionState.js'; +import { normalizeLegacyChatStateErrors } from './legacyProtocolCompatibility.js'; // --- Public API -------------------------------------------------------------- @@ -426,6 +427,10 @@ export class ChatStateSubscription extends BaseAgentSubscription { this._seqAllocator = seqAllocator; } + override handleSnapshot(state: ChatState, fromSeq: number): void { + super.handleSnapshot(normalizeLegacyChatStateErrors(state), fromSeq); + } + /** * Optimistically apply a chat action. Returns the clientSeq to send to * the server so it can echo back for reconciliation. diff --git a/src/vs/platform/agentHost/common/state/legacyProtocolCompatibility.ts b/src/vs/platform/agentHost/common/state/legacyProtocolCompatibility.ts new file mode 100644 index 00000000000..4cd0b0f9695 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/legacyProtocolCompatibility.ts @@ -0,0 +1,87 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { hasKey } from '../../../../base/common/types.js'; +import { ActionType, type ActionEnvelope, type ChatErrorAction, type StateAction } from './protocol/actions.js'; +import { ResponsePartKind, TurnState, type ChatState, type ErrorInfo, type Turn } from './protocol/state.js'; + +interface ILegacyChatErrorAction extends Omit { + readonly error: ErrorInfo; +} + +type CompatibleTurn = Turn | (Turn & { readonly error: ErrorInfo }); + +type CompatibleActionEnvelope = Omit & { + readonly action: StateAction | ILegacyChatErrorAction; +}; + +/** + * Reads the top-level error field emitted by AHP hosts before durable error + * response parts were introduced. + */ +export function readLegacyTurnError(turn: CompatibleTurn): ErrorInfo | undefined { + if (!hasKey(turn, { error: true })) { + return undefined; + } + return turn.error; +} + +/** + * Moves a legacy completed-turn error into its durable response-part position. + */ +export function normalizeLegacyTurnError(turn: CompatibleTurn): Turn { + if (turn.state !== TurnState.Error || !hasKey(turn, { error: true })) { + return turn; + } + + const { error, ...normalizedTurn } = turn; + const finalPart = turn.responseParts[turn.responseParts.length - 1]; + return { + ...normalizedTurn, + responseParts: finalPart?.kind === ResponsePartKind.Error + ? turn.responseParts + : [...turn.responseParts, { kind: ResponsePartKind.Error, error }], + }; +} + +/** + * Normalizes legacy completed-turn errors in a chat snapshot. + */ +export function normalizeLegacyChatStateErrors(state: ChatState): ChatState { + const turns = state.turns.map(normalizeLegacyTurnError); + return turns.some((turn, index) => turn !== state.turns[index]) + ? { ...state, turns } + : state; +} + +/** + * Normalizes legacy error payloads before a server action reaches reducers or + * action observers. + */ +export function normalizeLegacyActionEnvelope(envelope: CompatibleActionEnvelope): ActionEnvelope { + const action = envelope.action; + switch (action.type) { + case ActionType.ChatError: + if (hasKey(action, { error: true })) { + const { error, ...normalizedAction } = action; + return { + ...envelope, + action: { + ...normalizedAction, + part: { kind: ResponsePartKind.Error, error }, + }, + }; + } + return { ...envelope, action }; + case ActionType.ChatTurnsLoaded: { + const turns = action.turns.map(normalizeLegacyTurnError); + return turns.some((turn, index) => turn !== action.turns[index]) + ? { ...envelope, action: { ...action, turns } } + : { ...envelope, action }; + } + default: + return { ...envelope, action }; + } +} diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index fc634635294..2051fa1e7fe 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -f770e26b +b4016c0e diff --git a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts index b99a78eed64..e1e55844dc1 100644 --- a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts +++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts @@ -9,7 +9,7 @@ // Generated from types/actions.ts — do not edit // Run `npm run generate` to regenerate. -import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionChatAddedAction, type SessionChatRemovedAction, type SessionChatUpdatedAction, type SessionDefaultChatChangedAction, type SessionTitleChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionWorkingDirectorySetAction, type SessionWorkingDirectoryRemovedAction, type SessionWorkingDirectoryReplacedAction, type SessionInputNeededSetAction, type SessionInputNeededRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, type SessionMcpServerStartRequestedAction, type SessionMcpServerStopRequestedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChatTurnStartedAction, type ChatDeltaAction, type ChatResponsePartAction, type ChatToolCallStartAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallConfirmedAction, type ChatToolCallCompleteAction, type ChatToolCallResultConfirmedAction, type ChatToolCallContentChangedAction, type ChatToolCallAuthRequiredAction, type ChatToolCallAuthResolvedAction, type ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatActivityChangedAction, type ChatWorkingDirectorySetAction, type ChatWorkingDirectoryRemovedAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatDraftChangedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChatTurnsLoadedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetFilesReviewChangedAction, type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetOperationStatusChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, type AnnotationsUpdatedAction, type AnnotationsRemovedAction, type AnnotationsEntrySetAction, type AnnotationsEntryRemovedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction, type ResourceWatchChangedAction, type AutomationCreateRequestedAction, type AutomationUpdateRequestedAction, type AutomationSetAction, type AutomationRemovedAction, type AutomationRunLifecycleChangedAction, type AutomationRunSessionSetAction, type AutomationRunSessionRemovedAction, type AutomationRunPrimarySessionChangedAction, type AutomationRunCancelRequestedAction } from './actions.js'; +import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionChatAddedAction, type SessionChatRemovedAction, type SessionChatUpdatedAction, type SessionDefaultChatChangedAction, type SessionTitleChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionWorkingDirectorySetAction, type SessionWorkingDirectoryRemovedAction, type SessionWorkingDirectoryReplacedAction, type SessionInputNeededSetAction, type SessionInputNeededRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, type SessionMcpServerStartRequestedAction, type SessionMcpServerStopRequestedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChatTurnStartedAction, type ChatDeltaAction, type ChatResponsePartAction, type ChatToolCallStartAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallConfirmedAction, type ChatToolCallCompleteAction, type ChatToolCallResultConfirmedAction, type ChatToolCallContentChangedAction, type ChatToolCallAuthRequiredAction, type ChatToolCallAuthResolvedAction, type ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatTurnResumeAction, type ChatActivityChangedAction, type ChatWorkingDirectorySetAction, type ChatWorkingDirectoryRemovedAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatDraftChangedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChatTurnsLoadedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetFilesReviewChangedAction, type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetOperationStatusChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, type AnnotationsUpdatedAction, type AnnotationsRemovedAction, type AnnotationsEntrySetAction, type AnnotationsEntryRemovedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction, type ResourceWatchChangedAction, type AutomationCreateRequestedAction, type AutomationUpdateRequestedAction, type AutomationSetAction, type AutomationRemovedAction, type AutomationRunLifecycleChangedAction, type AutomationRunSessionSetAction, type AutomationRunSessionRemovedAction, type AutomationRunPrimarySessionChangedAction, type AutomationRunCancelRequestedAction } from './actions.js'; // ─── Root vs Session vs Chat vs Terminal vs Changeset Action Unions ───────────────── @@ -119,6 +119,7 @@ export type ChatAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction + | ChatTurnResumeAction | ChatActivityChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction @@ -143,6 +144,7 @@ export type ClientChatAction = | ChatToolCallResultConfirmedAction | ChatToolCallContentChangedAction | ChatTurnCancelledAction + | ChatTurnResumeAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction | ChatPendingMessageSetAction @@ -368,6 +370,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.ChatTurnComplete]: false, [ActionType.ChatTurnCancelled]: true, [ActionType.ChatError]: false, + [ActionType.ChatTurnResume]: true, [ActionType.ChatActivityChanged]: false, [ActionType.ChatWorkingDirectorySet]: true, [ActionType.ChatWorkingDirectoryRemoved]: true, diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-automation-run/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-automation-run/state.ts index ddabb2e3297..c3ca0a976ee 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-automation-run/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-automation-run/state.ts @@ -19,6 +19,7 @@ import type { SessionState } from '../channels-session/state.js'; * state is authoritative for those interactions. * * @category Automation Run State + * @exhaustive */ export const enum AutomationRunStatus { /** The durable run record exists but execution has not started. */ @@ -37,6 +38,7 @@ export const enum AutomationRunStatus { * Discriminant describing what created an automation run. * * @category Automation Run State + * @exhaustive */ export const enum AutomationRunOriginKind { /** A client explicitly invoked {@link RunAutomationParams | runAutomation}. */ diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-automation/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-automation/state.ts index 97cd10f2c4e..19ba4f56b3e 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-automation/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-automation/state.ts @@ -25,6 +25,7 @@ import type { FetchAutomationRunsParams, ListAutomationTriggerDefinitionsParams, * operations describe what is allowed for this particular automation now. * * @category Automation State + * @nonexhaustive */ export const enum AutomationOperation { /** Replace editable fields using {@link AutomationUpdateRequestedAction | `automation/updateRequested`}. */ @@ -80,6 +81,7 @@ export interface AutomationSchedule { * unavailable. * * @category Automation State + * @nonexhaustive */ export const enum AutomationMisfirePolicy { /** Discard missed occurrences and wait for the next future occurrence. */ @@ -95,6 +97,7 @@ export const enum AutomationMisfirePolicy { * Discriminant for automatic trigger definitions. * * @category Automation State + * @exhaustive */ export const enum AutomationTriggerKind { /** A portable recurring {@link AutomationSchedule}. */ diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-changeset/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-changeset/commands.ts index 465cb8fd814..5603c181af5 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-changeset/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-changeset/commands.ts @@ -17,6 +17,7 @@ import type { BaseParams } from '../common/commands.js'; * `Changeset` scope has no target. * * @category Commands + * @nonexhaustive */ export const enum ChangesetOperationTargetKind { /** Operation acts on a single file. */ diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-changeset/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-changeset/state.ts index 5de8f43de93..4ba5ed72f62 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-changeset/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-changeset/state.ts @@ -102,6 +102,7 @@ export interface ChangesetCapabilities { * Computation lifecycle of a {@link ChangesetState}. * * @category Changesets + * @nonexhaustive */ export const enum ChangesetStatus { /** The server is still computing the contents of this changeset. */ @@ -191,6 +192,7 @@ export interface ChangesetFile { * Pull Request" button, or an inline error after a failed "revert"). * * @category Changesets + * @nonexhaustive */ export const enum ChangesetOperationStatus { /** @@ -215,6 +217,7 @@ export const enum ChangesetOperationStatus { * Where a {@link ChangesetOperation} can be invoked. * * @category Changesets + * @nonexhaustive */ export const enum ChangesetOperationScope { /** Applies to the whole changeset. */ diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts index 639db5ba163..ba619a4bd39 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts @@ -7,9 +7,9 @@ // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts import { ActionType } from '../common/actions.js'; -import type { StringOrMarkdown, ErrorInfo, FileEdit, UsageInfo, URI } from '../common/state.js'; +import type { StringOrMarkdown, FileEdit, UsageInfo, URI } from '../common/state.js'; import type { McpAuthRequirement } from '../channels-session/state.js'; -import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type Message, type ResponsePart, type ToolCallResult, type ToolResultContent, type ChatInputAnswer, type ChatInputRequest, type ChatInputResponseKind, type ConfirmationOption, type ToolCallContributor, type ToolCallRiskAssessment, type ToolInput, type Turn } from './state.js'; +import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type Message, type ResponsePart, type ToolCallResult, type ToolResultContent, type ChatInputAnswer, type ChatInputRequest, type ChatInputResponseKind, type ConfirmationOption, type ErrorResponsePart, type ToolCallContributor, type ToolCallRiskAssessment, type ToolInput, type Turn } from './state.js'; // ─── Tool Call Action Base ─────────────────────────────────────────────────── @@ -74,7 +74,7 @@ export interface ChatTurnStartedAction { * Streaming text chunk from the assistant, appended to a specific response part. * * The server MUST first emit a `chat/responsePart` to create the target - * part (markdown or reasoning), then use this action to append text to it. + * markdown part, then use this action to append text to it. * * @category Chat Actions * @version 1 @@ -102,6 +102,9 @@ export interface ChatDeltaAction { /** * Structured content appended to the response. * + * An {@link ErrorResponsePart} MUST be appended with {@link ChatErrorAction} + * instead so adding the part and ending the turn are one atomic transition. + * * @category Chat Actions * @version 1 */ @@ -109,7 +112,7 @@ export interface ChatResponsePartAction { type: ActionType.ChatResponsePart; /** Turn identifier */ turnId: string; - /** Response part (markdown or content ref) */ + /** Response part to append; error parts are ignored. */ part: ResponsePart; /** * Additional provider-specific metadata for this action. @@ -472,8 +475,11 @@ export interface ChatErrorAction { * data. */ duration: number; - /** Error details */ - error: ErrorInfo; + /** + * Error part to append to the response stream before finalizing the turn. + * Its optional `resumable` flag indicates whether the turn can be resumed. + */ + part: ErrorResponsePart; /** * Additional provider-specific metadata for this action. * @@ -486,6 +492,24 @@ export interface ChatErrorAction { _meta?: Record; } +/** + * Resumes the latest errored turn without adding another message. + * + * The turn MUST be the latest turn, its state MUST be `error`, and its final + * response part MUST be a resumable error. The reducer reopens the same turn + * with its existing message, response parts, and usage intact. The host then + * resumes the provider's execution for that turn. + * + * @category Chat Actions + * @version 1 + * @clientDispatchable + */ +export interface ChatTurnResumeAction { + type: ActionType.ChatTurnResume; + /** Identifier of the errored turn. */ + turnId: string; +} + /** * The activity description of this chat changed. * @@ -805,6 +829,7 @@ export type ChatAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction + | ChatTurnResumeAction | ChatActivityChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts index aafbf7f78a3..e298e76e06f 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts @@ -14,6 +14,7 @@ import type { Message, SideChatSelection } from './state.js'; /** * How a new chat uses its source chat and turn. + * @nonexhaustive */ export const enum ChatSourceKind { /** Copy source history through the referenced turn into the new chat. */ diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts index c9806db7e8a..5cf707cfc1f 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts @@ -7,7 +7,7 @@ // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts import { ActionType } from '../common/actions.js'; -import { TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, ToolCallContributorKind, ResponsePartKind, PendingMessageKind, type ChatState, type ToolCallState, type ResponsePart, type ToolCallResponsePart, type InputRequestResponsePart, type Turn, type PendingMessage, type ConfirmationOption, type ToolCallContributor } from './state.js'; +import { TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, ToolCallContributorKind, ResponsePartKind, PendingMessageKind, type ChatState, type ToolCallState, type ResponsePart, type ToolCallResponsePart, type InputRequestResponsePart, type ErrorResponsePart, type Turn, type PendingMessage, type ConfirmationOption, type ToolCallContributor } from './state.js'; import { SessionStatus } from '../channels-session/state.js'; import type { ChatAction } from '../action-origin.generated.js'; import { softAssertNever } from '../common/reducer-helpers.js'; @@ -104,6 +104,15 @@ function findOpenInputRequestPart( return part.kind === ResponsePartKind.InputRequest ? { index, part } : undefined; } +function hasResumableError(turn: Turn): boolean { + const part = turn.responseParts[turn.responseParts.length - 1]; + return part?.kind === ResponsePartKind.Error && part.resumable === true; +} + +function isErrorResponsePart(part: ResponsePart): part is ErrorResponsePart { + return part.kind === ResponsePartKind.Error; +} + /** Bitmask covering the mutually-exclusive activity bits (bits 0–4). */ const STATUS_ACTIVITY_MASK = (1 << 5) - 1; @@ -153,7 +162,7 @@ function endTurn( turnState: TurnState, duration: number, terminalStatus?: SessionStatus.Error, - error?: { errorType: string; message: string; stack?: string }, + errorPart?: ErrorResponsePart, ): ChatState { if (!state.activeTurn || state.activeTurn.id !== turnId) { return state; @@ -180,6 +189,9 @@ function endTurn( }, }; }); + if (errorPart) { + responseParts.push(errorPart); + } const turn: Turn = { id: active.id, @@ -191,7 +203,6 @@ function endTurn( responseParts, usage: active.usage, state: turnState, - error, }; const next: ChatState = { @@ -368,6 +379,9 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st if (!state.activeTurn || state.activeTurn.id !== action.turnId) { return state; } + if (isErrorResponsePart(action.part)) { + return state; + } return { ...state, activeTurn: { @@ -383,7 +397,35 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st return endTurn(state, action.turnId, TurnState.Cancelled, action.duration); case ActionType.ChatError: - return endTurn(state, action.turnId, TurnState.Error, action.duration, SessionStatus.Error, action.error); + return endTurn(state, action.turnId, TurnState.Error, action.duration, SessionStatus.Error, action.part); + + case ActionType.ChatTurnResume: { + if (state.activeTurn) { + return state; + } + const turnIndex = state.turns.length - 1; + const turn = state.turns[turnIndex]; + if (!turn || turn.id !== action.turnId || turn.state !== TurnState.Error || !hasResumableError(turn)) { + return state; + } + const turns = state.turns.slice(); + turns.splice(turnIndex, 1); + const next: ChatState = { + ...state, + turns, + activeTurn: { + id: turn.id, + startedAt: turn.startedAt ?? state.modifiedAt, + message: turn.message, + responseParts: turn.responseParts, + usage: turn.usage, + }, + }; + return { + ...next, + status: withStatusFlag(summaryStatus(next), SessionStatus.IsRead, false), + }; + } case ActionType.ChatActivityChanged: return { ...state, activity: action.activity }; diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts index d3604a541d0..dbae61c6f38 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts @@ -141,6 +141,7 @@ export interface ChatSummary { * Discriminant for {@link ChatOrigin} — how a chat came into existence. * * @category Chat State + * @nonexhaustive */ export const enum ChatOriginKind { /** User created the chat explicitly (e.g. via the host UI). */ @@ -224,6 +225,7 @@ export type ChatOrigin = * the UI uses it to show appropriate controls. * * @category Chat State + * @exhaustive */ export const enum ChatInteractivity { /** User can send messages and watch (default when absent) */ @@ -240,6 +242,7 @@ export const enum ChatInteractivity { * Discriminant for pending message kinds. * * @category Pending Message Types + * @exhaustive */ export const enum PendingMessageKind { /** Injected into the current turn at a convenient point */ @@ -271,6 +274,7 @@ export interface PendingMessage { * How a client completed an input request. * * @category Chat Input Types + * @exhaustive */ export const enum ChatInputResponseKind { Accept = 'accept', @@ -282,6 +286,7 @@ export const enum ChatInputResponseKind { * Question/input control kind. * * @category Chat Input Types + * @nonexhaustive */ export const enum ChatInputQuestionKind { Text = 'text', @@ -418,6 +423,7 @@ export interface ChatInputRequest { * Answer value kind. * * @category Chat Input Types + * @nonexhaustive */ export const enum ChatInputAnswerValueKind { Text = 'text', @@ -486,6 +492,7 @@ export interface ChatInputSkipped { * Answer lifecycle state. * * @category Chat Input Types + * @exhaustive */ export const enum ChatInputAnswerState { Draft = 'draft', @@ -507,6 +514,7 @@ export type ChatInputAnswer = ChatInputAnswered | ChatInputSkipped; * How a turn ended. * * @category Turn Types + * @exhaustive */ export const enum TurnState { Complete = 'complete', @@ -518,6 +526,7 @@ export const enum TurnState { * Discriminant for {@link MessageAttachment} variants. * * @category Turn Types + * @nonexhaustive */ export const enum MessageAttachmentKind { /** A simple, opaque attachment whose representation is described by the producer. */ @@ -557,8 +566,6 @@ export interface Turn { usage: UsageInfo | undefined; /** How the turn ended */ state: TurnState; - /** Error details if state is `'error'` */ - error?: ErrorInfo; } /** @@ -587,6 +594,7 @@ export interface ActiveTurn { * Discriminant for {@link MessageOrigin} — identifies who produced a message. * * @category Turn Types + * @nonexhaustive */ export enum MessageKind { /** Sent directly by the user. */ @@ -849,6 +857,7 @@ export type MessageAttachment = * Discriminant for response part types. * * @category Response Parts + * @nonexhaustive */ export const enum ResponsePartKind { Markdown = 'markdown', @@ -857,6 +866,7 @@ export const enum ResponsePartKind { Reasoning = 'reasoning', SystemNotification = 'systemNotification', InputRequest = 'inputRequest', + Error = 'error', } /** @@ -920,7 +930,8 @@ export type ResponsePart = | ToolCallResponsePart | ReasoningResponsePart | SystemNotificationResponsePart - | InputRequestResponsePart; + | InputRequestResponsePart + | ErrorResponsePart; /** * A live or resolved input request (elicitation) in the turn response stream. @@ -951,6 +962,28 @@ export interface InputRequestResponsePart { response?: ChatInputResponseKind; } +/** + * An error encountered while processing a turn. + * + * This is the detailed source of truth for the error. {@link Turn.state} + * remains {@link TurnState.Error} while the turn is stopped at this error so + * clients can detect the terminal state without inspecting response parts. + * + * When {@link resumable} is `true`, a client may dispatch `chat/turnResume` + * while this is the latest turn and its state is {@link TurnState.Error}. + * Clients decide whether and how to present that affordance. + * + * @category Response Parts + */ +export interface ErrorResponsePart { + /** Discriminant */ + kind: ResponsePartKind.Error; + /** Error details. */ + error: ErrorInfo; + /** Whether the host can resume the turn from this error. Only `true` enables resume. */ + resumable?: boolean; +} + /** * A system notification surfaced as part of the response stream. * @@ -985,6 +1018,7 @@ export interface SystemNotificationResponsePart { * Status of a tool call in the lifecycle state machine. * * @category Tool Call Types + * @nonexhaustive */ export const enum ToolCallStatus { Streaming = 'streaming', @@ -1009,6 +1043,7 @@ export const enum ToolCallStatus { * - `Setting` — Approved by a persistent user setting * * @category Tool Call Types + * @nonexhaustive */ export const enum ToolCallConfirmationReason { NotNeeded = 'not-needed', @@ -1020,6 +1055,7 @@ export const enum ToolCallConfirmationReason { * Identifies a model judge as the source of a confirmation requirement. * * @category Tool Call Types + * @nonexhaustive */ export const enum ToolCallRiskAssessmentKind { Judge = 'judge', @@ -1029,6 +1065,7 @@ export const enum ToolCallRiskAssessmentKind { * Lifecycle status of an asynchronous model-judge confirmation decision. * * @category Tool Call Types + * @nonexhaustive */ export const enum ToolCallRiskAssessmentStatus { Loading = 'loading', @@ -1071,6 +1108,7 @@ export type ToolCallRiskAssessment = * Why a tool call was cancelled. * * @category Tool Call Types + * @exhaustive */ export const enum ToolCallCancellationReason { Denied = 'denied', @@ -1082,6 +1120,7 @@ export const enum ToolCallCancellationReason { * Whether a confirmation option represents an approval or denial action. * * @category Tool Call Types + * @nonexhaustive */ export const enum ConfirmationOptionKind { Approve = 'approve', @@ -1112,6 +1151,12 @@ export interface ConfirmationOption { group?: number; } +/** + * Identifies the source of a tool call's implementation. + * + * @category Tool Call Types + * @nonexhaustive + */ export const enum ToolCallContributorKind { Client = 'client', MCP = 'mcp', @@ -1416,6 +1461,7 @@ export type ToolCallConfirmationState = * Discriminant for tool result content types. * * @category Tool Result Content + * @nonexhaustive */ export const enum ToolResultContentType { Text = 'text', diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-resource-watch/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-resource-watch/commands.ts index 8b79b580897..37eddf25445 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-resource-watch/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-resource-watch/commands.ts @@ -16,7 +16,7 @@ import type { BaseParams } from '../common/commands.js'; * * The receiver allocates an `ahp-resource-watch:/` channel URI and * returns it on {@link CreateResourceWatchResult.channel}. The caller then - * [`subscribe`](./subscriptions)s to that channel to receive + * [`subscribe`](/specification/subscriptions#subscribe-request)s to that channel to receive * `resourceWatch/changed` actions over the standard action envelope. * * The watch lifecycle is tied to subscription: when every subscriber has diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-resource-watch/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-resource-watch/state.ts index 1cb3d796e81..53e9b210082 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-resource-watch/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-resource-watch/state.ts @@ -53,6 +53,7 @@ export interface ResourceWatchState { * Discriminant for {@link ResourceChange.type}. * * @category Resource Watch Types + * @exhaustive */ export const enum ResourceChangeType { Added = 'added', diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts index 1fc51441069..51ce7c2508d 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts @@ -16,6 +16,7 @@ import type { Customization } from '../channels-session/state.js'; * Policy configuration state for a model. * * @category Root State + * @exhaustive */ export const enum PolicyState { Enabled = 'enabled', diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts index 47f50f9ecbd..3492da93cad 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts @@ -163,6 +163,7 @@ export interface FetchTurnsResult { } * The kind of completion items being requested. * * @category Commands + * @nonexhaustive */ export const enum CompletionItemKind { /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts index a9105b124be..4a15a7e80de 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts @@ -8,7 +8,7 @@ import type { Changeset } from '../channels-changeset/state.js'; import type { AnnotationsSummary } from '../channels-annotations/state.js'; -import type { ChatSummary, ChatInputRequest, ToolCallConfirmationState, ToolCallState, ToolCallAuthRequiredState } from '../channels-chat/state.js'; +import type { ChatSummary, ChatInputRequest, ToolCallConfirmationState, ToolCallRunningState, ToolCallAuthRequiredState } from '../channels-chat/state.js'; import type { AutomationRunState } from '../channels-automation-run/state.js'; import type { AutomationState } from '../channels-automation/state.js'; import type { ConfigPropertySchema, ErrorInfo, Icon, ProtectedResourceMetadata, TextRange, URI } from '../common/state.js'; @@ -19,6 +19,7 @@ import type { ConfigPropertySchema, ErrorInfo, Icon, ProtectedResourceMetadata, * Session initialization state. * * @category Session State + * @nonexhaustive */ export const enum SessionLifecycle { Creating = 'creating', @@ -34,6 +35,7 @@ export const enum SessionLifecycle { * and turns that are paused waiting for input. * * @category Session State + * @nonexhaustive */ export const enum SessionStatus { /** Session is idle — no turn is active. */ @@ -54,6 +56,7 @@ export const enum SessionStatus { * Discriminant describing the durable provenance of a session. * * @category Session State + * @nonexhaustive */ export const enum SessionOriginKind { /** The session was created as part of an automation run. */ @@ -270,6 +273,7 @@ export interface SessionActiveClient { * a `*Kind`. * * @category Session Input Types + * @nonexhaustive */ export const enum SessionInputRequestKind { /** A user-facing elicitation mirrored from an unresolved chat response part. */ @@ -372,10 +376,9 @@ export interface SessionToolClientExecutionRequest extends SessionInputRequestBa clientId: string; /** * The running tool call the session wants the owning client to execute. The - * host only ever populates this with a {@link ToolCallRunningState} (i.e. a - * {@link ToolCallState} in `running` status). + * host only ever populates this with a {@link ToolCallRunningState}. */ - toolCall: ToolCallState; + toolCall: ToolCallRunningState; } /** @@ -661,6 +664,7 @@ export interface ToolAnnotations { * a container. * * @category Customization Types + * @nonexhaustive */ export const enum CustomizationType { Plugin = 'plugin', @@ -677,6 +681,7 @@ export const enum CustomizationType { * Scope at which customization enablement is decided. * * @category Customization Types + * @nonexhaustive */ export const enum CustomizationEnablementKind { Global = 'global', @@ -751,6 +756,7 @@ interface CustomizationBase { * Discriminant values for {@link CustomizationLoadState}. * * @category Customization Types + * @exhaustive */ export const enum CustomizationLoadStatus { Loading = 'loading', @@ -1242,6 +1248,7 @@ export type Customization = * Discriminant for the {@link McpServerState} union. * * @category MCP Server State + * @nonexhaustive */ export const enum McpServerStatus { /** Server has been registered but is not yet running. */ @@ -1268,6 +1275,7 @@ export const enum McpServerStatus { * [MCP authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization.md). * * @category MCP Server State + * @nonexhaustive */ export const enum McpAuthRequiredReason { /** No token has been provided yet (HTTP 401, no prior token). */ diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-terminal/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-terminal/state.ts index 238319265d5..bd61ef9fdb9 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-terminal/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-terminal/state.ts @@ -30,6 +30,7 @@ export interface TerminalInfo { * Lifecycle status of a terminal process. * * @category Terminal Types + * @exhaustive */ export const enum TerminalLifecycleStatus { Running = 'running', @@ -69,6 +70,7 @@ export type TerminalLifecycleState = * Discriminant for terminal claim kinds. * * @category Terminal Types + * @exhaustive */ export const enum TerminalClaimKind { Client = 'client', diff --git a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts index 939f01868f9..97ef944059b 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts @@ -12,7 +12,7 @@ import type { RootAgentsChangedAction, RootActiveSessionsChangedAction, RootTerm import type { SessionReadyAction, SessionCreationFailedAction, SessionChatAddedAction, SessionChatRemovedAction, SessionChatUpdatedAction, SessionDefaultChatChangedAction, SessionTitleChangedAction, SessionServerToolsChangedAction, SessionActiveClientSetAction, SessionActiveClientRemovedAction, SessionWorkingDirectorySetAction, SessionWorkingDirectoryRemovedAction, SessionWorkingDirectoryReplacedAction, SessionInputNeededSetAction, SessionInputNeededRemovedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionCustomizationRemovedAction, SessionMcpServerStateChangedAction, SessionMcpServerStartRequestedAction, SessionMcpServerStopRequestedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction } from '../channels-session/actions.js'; -import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatToolCallAuthRequiredAction, ChatToolCallAuthResolvedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatActivityChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatDraftChangedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction, ChatTurnsLoadedAction } from '../channels-chat/actions.js'; +import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatToolCallAuthRequiredAction, ChatToolCallAuthResolvedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatTurnResumeAction, ChatActivityChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatDraftChangedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction, ChatTurnsLoadedAction } from '../channels-chat/actions.js'; import type { ChangesetStatusChangedAction, ChangesetFileSetAction, ChangesetFileRemovedAction, ChangesetFilesReviewChangedAction, ChangesetContentChangedAction, ChangesetOperationsChangedAction, ChangesetOperationStatusChangedAction, ChangesetClearedAction } from '../channels-changeset/actions.js'; @@ -30,6 +30,7 @@ import type { AutomationRunLifecycleChangedAction, AutomationRunSessionSetAction * Discriminant values for all state actions. * * @category Actions + * @nonexhaustive */ export const enum ActionType { RootAgentsChanged = 'root/agentsChanged', @@ -55,6 +56,7 @@ export const enum ActionType { ChatTurnComplete = 'chat/turnComplete', ChatTurnCancelled = 'chat/turnCancelled', ChatError = 'chat/error', + ChatTurnResume = 'chat/turnResume', ChatActivityChanged = 'chat/activityChanged', ChatWorkingDirectorySet = 'chat/workingDirectorySet', ChatWorkingDirectoryRemoved = 'chat/workingDirectoryRemoved', @@ -210,6 +212,7 @@ export type StateAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction + | ChatTurnResumeAction | ChatActivityChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction diff --git a/src/vs/platform/agentHost/common/state/protocol/common/commands.ts b/src/vs/platform/agentHost/common/state/protocol/common/commands.ts index da4e14e6140..d068aa70921 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/commands.ts @@ -157,7 +157,8 @@ export interface InitializeParams extends BaseParams { * * The server selects one entry and returns it as `InitializeResult.protocolVersion`. * If the server cannot speak any of the offered versions, it MUST return - * error code `-32005` (`UnsupportedProtocolVersion`). + * error code `-32005` (`UnsupportedProtocolVersion`) with required + * `UnsupportedProtocolVersionErrorData` containing `supportedVersions`. */ protocolVersions: string[]; /** Unique client identifier */ @@ -221,7 +222,8 @@ export interface ClientCapabilities { * `protocolVersions` list. The client and server MUST use this version for * the rest of the connection. If the server cannot speak any of the offered * versions it MUST return error code `-32005` (`UnsupportedProtocolVersion`) - * instead of a result. + * with required `UnsupportedProtocolVersionErrorData` containing + * `supportedVersions`, instead of a result. */ export interface InitializeResult { /** @@ -374,6 +376,7 @@ export interface PingParams extends BaseParams { * Discriminant for reconnect result types. * * @category Commands + * @exhaustive */ export const enum ReconnectResultType { Replay = 'replay', @@ -563,6 +566,7 @@ export interface DispatchActionParams { * Encoding of fetched content data. * * @category Commands + * @exhaustive */ export const enum ContentEncoding { Base64 = 'base64', @@ -653,6 +657,7 @@ export interface ResourceReadResult { * the file — use `truncate` to overwrite bytes in place. * * @category Commands + * @exhaustive */ export const enum ResourceWriteMode { Truncate = 'truncate', @@ -967,6 +972,7 @@ export interface ResourceMoveResult { * Discriminant for {@link ResourceResolveResult.type}. * * @category Commands + * @nonexhaustive */ export const enum ResourceType { File = 'file', diff --git a/src/vs/platform/agentHost/common/state/protocol/common/errors.ts b/src/vs/platform/agentHost/common/state/protocol/common/errors.ts index 7cec2e9d24f..0fdab1d1f5e 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/errors.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/errors.ts @@ -49,12 +49,10 @@ export const AhpErrorCodes = { /** * The server cannot speak any of the protocol versions offered by the * client in `InitializeParams.protocolVersions`. The `data` field of the - * JSON-RPC error MAY be an `UnsupportedProtocolVersionErrorData` advertising - * the protocol versions the server is willing to speak. + * JSON-RPC error MUST carry an `UnsupportedProtocolVersionErrorData` + * advertising the protocol versions the server is willing to speak. */ UnsupportedProtocolVersion: -32005, - /** The requested content URI does not exist */ - ContentNotFound: -32006, /** * A command failed because the client has not authenticated for a required * protected resource. The `data` field of the JSON-RPC error MUST be an @@ -142,6 +140,8 @@ export interface PermissionDeniedErrorData { * Details carried in the `data` field of an `UnsupportedProtocolVersion` * (-32005) error. * + * The data payload is required and always carries `supportedVersions`. + * * @category Error Details * @version 1 */ diff --git a/src/vs/platform/agentHost/common/state/protocol/common/notifications.ts b/src/vs/platform/agentHost/common/state/protocol/common/notifications.ts index cdc16daf98d..caff1892668 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/notifications.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/notifications.ts @@ -12,6 +12,7 @@ import type { ProtectedResourceMetadata, URI } from './state.js'; * Reason why authentication is required. * * @category Protocol Notifications + * @nonexhaustive */ export const enum AuthRequiredReason { /** The client has not yet authenticated for the resource */ diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index c1a5afe50bf..cda0b8fa5b3 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -126,6 +126,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChatTurnComplete]: '0.4.0', [ActionType.ChatTurnCancelled]: '0.4.0', [ActionType.ChatError]: '0.4.0', + [ActionType.ChatTurnResume]: '1.0.0', [ActionType.ChatActivityChanged]: '0.5.0', [ActionType.ChatWorkingDirectorySet]: '0.7.0', [ActionType.ChatWorkingDirectoryRemoved]: '0.7.0', diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 80ebdf7d035..645e2dc6815 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -16,10 +16,12 @@ import { hasKey, type Mutable } from '../../../../base/common/types.js'; import { URI as ResourceURI } from '../../../../base/common/uri.js'; import type { IProductService } from '../../../product/common/productService.js'; import { readToolCallMeta } from '../meta/agentToolCallMeta.js'; +import { readLegacyTurnError } from './legacyProtocolCompatibility.js'; import { ResponsePartKind, SessionStatus, ToolCallStatus, + TurnState, SessionLifecycle, TerminalState, ToolResultContentType, @@ -30,6 +32,7 @@ import { type ChangesetState, type ChatState, type ChatSummary, + type ErrorInfo, type PendingMessage, type Turn, type AnnotationsState, @@ -67,7 +70,7 @@ export { type ConfigSchema, type ContentRef, type Customization, type CustomizationDegradedState, type CustomizationErrorState, type CustomizationLoadedState, type CustomizationLoadingState, type CustomizationLoadState, type DirectoryCustomization, type ErrorInfo, type HookCustomization, type FileEdit as ISessionFileDiff, type ToolResultEmbeddedResourceContent as IToolResultBinaryContent, type MarkdownResponsePart, type McpServerCustomization, type MessageAttachment, - type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type MessageAnnotationsAttachment, type MessageChatAttachment, type ModelSelection, type PendingMessage, type PluginCustomization, type ProjectInfo, type PromptCustomization, type ReasoningResponsePart, + type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type MessageAnnotationsAttachment, type MessageChatAttachment, type ModelSelection, type PendingMessage, type PluginCustomization, type ProjectInfo, type PromptCustomization, type ReasoningResponsePart, type ErrorResponsePart, type ResponsePart, type RootState, type RuleCustomization, type SessionActiveClient, type SessionConfigState, type SessionModelInfo, @@ -929,6 +932,14 @@ export function createActiveTurn(id: string, message: Message, startedAt: string }; } +export function getTurnError(turn: Turn | undefined): ErrorInfo | undefined { + if (turn?.state !== TurnState.Error) { + return undefined; + } + const part = turn.responseParts[turn.responseParts.length - 1]; + return part?.kind === ResponsePartKind.Error ? part.error : readLegacyTurnError(turn); +} + export const enum StateComponents { Root, Session, diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index b67b1b6d612..5253a07b1b1 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -1041,9 +1041,9 @@ export class AgentSideEffects extends Disposable { if (action.type === ActionType.ChatError) { const clientContext = this._turnTracker.getClientTelemetryContext(sessionKey, turnId); - this._completeTurn(sessionKey, turnId, 'error', { stage: 'provider', error: action.error }); + this._completeTurn(sessionKey, turnId, 'error', { stage: 'provider', error: action.part.error }); this._toolCallTracker.clearSession(sessionKey); - this._chatContributions.turnEnd({ session: sessionUri, channel: sessionKey, turnId, reason: { kind: 'error', error: action.error }, clientContext }); + this._chatContributions.turnEnd({ session: sessionUri, channel: sessionKey, turnId, reason: { kind: 'error', error: action.part.error }, clientContext }); } } @@ -1505,7 +1505,7 @@ export class AgentSideEffects extends Disposable { type: ActionType.ChatError, turnId: action.turnId, duration: this._turnDuration(turnStopWatch), - error: { errorType: 'noAgent', message: 'No agent found for session' }, + part: { kind: ResponsePartKind.Error, error: { errorType: 'noAgent', message: 'No agent found for session' } }, }); return; } @@ -1883,7 +1883,7 @@ export class AgentSideEffects extends Disposable { type: ActionType.ChatError, turnId, duration: this._turnDuration(turnStopWatch), - error, + part: { kind: ResponsePartKind.Error, error }, }); this._completeTurn(turnChannel, turnId, 'error', { stage: 'validation', error }); this._toolCallTracker.clearSession(turnChannel); @@ -1939,7 +1939,7 @@ export class AgentSideEffects extends Disposable { type: ActionType.ChatError, turnId, duration: this._turnDuration(turnStopWatch), - error, + part: { kind: ResponsePartKind.Error, error }, }); this._completeTurn(turnChannel, turnId, 'error', failure); this._toolCallTracker.clearSession(turnChannel); diff --git a/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts b/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts index 792e25ecdf7..050384a69dc 100644 --- a/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts @@ -12,7 +12,7 @@ import { AgentHostClientType } from '../../../common/agentHostClientInfo.js'; import { createUnknownAgentHostClientTelemetryContext } from '../../../common/agentHostTelemetry.js'; import { IAgentHostChatContributions, createChatMementoKey, type IAgentHostChatContribution, type IAgentHostChatContributionContext, type IAgentHostChatContributionHost, type IObservedAction, type IQueuedMessageSender, type ITurnEnd } from '../../../common/agentHostChatContributionsService.js'; import { ActionType } from '../../../common/state/sessionActions.js'; -import { isAhpChatChannel, parseRequiredSessionUriFromChatUri, PendingMessageKind, type Message, type URI as ProtocolURI } from '../../../common/state/sessionState.js'; +import { isAhpChatChannel, parseRequiredSessionUriFromChatUri, PendingMessageKind, ResponsePartKind, type Message, type URI as ProtocolURI } from '../../../common/state/sessionState.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; import { createAgentChatContext } from '../../agentChatContext.js'; import { IAgentHostProviderLocator } from '../../agentHostProviderLocator.js'; @@ -147,7 +147,7 @@ export class QueueDrainContribution extends Disposable implements IAgentHostChat type: ActionType.ChatError, turnId, duration: Math.max(0, turnStopWatch.elapsed()), - error: { errorType: 'noAgent', message: 'No agent found for session' }, + part: { kind: ResponsePartKind.Error, error: { errorType: 'noAgent', message: 'No agent found for session' } }, }); return; } diff --git a/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts b/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts index 143edc009e7..37f33bce6e0 100644 --- a/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts @@ -498,9 +498,12 @@ function mapResult( type: ActionType.ChatError, turnId, duration: typeof turnDuration === 'number' && Number.isFinite(turnDuration) ? Math.max(0, turnDuration) : 0, - error: { - errorType: message.subtype, - ...extractForwardedErrorInfo(errorText), + part: { + kind: ResponsePartKind.Error, + error: { + errorType: message.subtype, + ...extractForwardedErrorInfo(errorText), + }, }, }, }); diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 1b1154e2a0d..1d9af8203a1 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -3392,7 +3392,7 @@ export class CodexAgent extends Disposable implements IAgent { type: ActionType.ChatError, turnId, duration, - error: { errorType: 'CodexDisconnected', message: 'Codex app-server disconnected; session must restart.' }, + part: { kind: ResponsePartKind.Error, error: { errorType: 'CodexDisconnected', message: 'Codex app-server disconnected; session must restart.' } }, }); this._fire(session.sessionUri, { type: ActionType.ChatTurnComplete, turnId, duration }); } @@ -4845,7 +4845,7 @@ export class CodexAgent extends Disposable implements IAgent { type: ActionType.ChatError, turnId: effectiveTurnId, duration, - error: { errorType: 'CodexMaterializeFailed', message }, + part: { kind: ResponsePartKind.Error, error: { errorType: 'CodexMaterializeFailed', message } }, }); this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId, duration }); return; @@ -4883,7 +4883,7 @@ export class CodexAgent extends Disposable implements IAgent { type: ActionType.ChatError, turnId: effectiveTurnId, duration, - error: { errorType: 'CodexMaterializeFailed', message }, + part: { kind: ResponsePartKind.Error, error: { errorType: 'CodexMaterializeFailed', message } }, }); this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId, duration }); return; @@ -4903,9 +4903,12 @@ export class CodexAgent extends Disposable implements IAgent { type: ActionType.ChatError, turnId: effectiveTurnId, duration, - error: { - errorType: 'CodexResumeFailed', - message: err instanceof Error ? err.message : String(err), + part: { + kind: ResponsePartKind.Error, + error: { + errorType: 'CodexResumeFailed', + message: err instanceof Error ? err.message : String(err), + }, }, }); this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId, duration }); @@ -4965,7 +4968,7 @@ export class CodexAgent extends Disposable implements IAgent { type: ActionType.ChatError, turnId: effectiveTurnId, duration, - error: { errorType: isCompactCommand ? 'CodexCompactionError' : 'CodexTurnError', ...extractForwardedErrorInfo(message) }, + part: { kind: ResponsePartKind.Error, error: { errorType: isCompactCommand ? 'CodexCompactionError' : 'CodexTurnError', ...extractForwardedErrorInfo(message) } }, }); this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId, duration }); } finally { diff --git a/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts b/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts index e0aa3a1251b..c2798a18f40 100644 --- a/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts +++ b/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts @@ -1239,7 +1239,7 @@ export function mapTurnCompleted( type: ActionType.ChatError, turnId, duration, - error: mapCodexTurnError(params.turn.error), + part: { kind: ResponsePartKind.Error, error: mapCodexTurnError(params.turn.error) }, }, { type: ActionType.ChatTurnComplete, diff --git a/src/vs/platform/agentHost/node/codex/codexReplayMapper.ts b/src/vs/platform/agentHost/node/codex/codexReplayMapper.ts index cfa6b1b198e..9833232669a 100644 --- a/src/vs/platform/agentHost/node/codex/codexReplayMapper.ts +++ b/src/vs/platform/agentHost/node/codex/codexReplayMapper.ts @@ -206,6 +206,9 @@ function replayTurnToTurn(codexTurn: CodexTurn, model: ModelSelection | undefine if (!userText && parts.length === 0) { return undefined; } + if (codexTurn.status === 'failed' && codexTurn.error) { + parts.push({ kind: ResponsePartKind.Error, error: mapCodexTurnError(codexTurn.error) }); + } return { id: codexTurn.id, ...codexTurnTiming(codexTurn), @@ -218,7 +221,6 @@ function replayTurnToTurn(codexTurn: CodexTurn, model: ModelSelection | undefine responseParts: parts, usage: model ? { model: model.id } : undefined, state: turnStateFromStatus(codexTurn.status), - ...(codexTurn.status === 'failed' && codexTurn.error ? { error: mapCodexTurnError(codexTurn.error) } : {}), }; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 34d38ddaf7f..21a1d8e9b63 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -1541,7 +1541,7 @@ export class CopilotAgentSession extends Disposable { type: ActionType.ChatError, turnId: turn.id, duration: turn.duration, - error, + part: { kind: ResponsePartKind.Error, error }, }); this._clearActiveTurn(); return turn.id; @@ -4770,7 +4770,7 @@ export class CopilotAgentSession extends Disposable { type: ActionType.ChatError, turnId: this._turnId, duration: turn?.duration ?? 0, - error: buildChatErrorInfoFromCopilotSdkFields(e.data), + part: { kind: ResponsePartKind.Error, error: buildChatErrorInfoFromCopilotSdkFields(e.data) }, }); })); diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index 282e3d80a51..66677f300e8 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -284,10 +284,11 @@ function finalizeTurn(builder: ITurnBuilder, state: TurnState): Turn { ...(builder.startedAt !== undefined ? { startedAt: builder.startedAt } : {}), ...(duration !== undefined ? { duration } : {}), message: builder.message, - responseParts: builder.responseParts, + responseParts: builder.error + ? [...builder.responseParts, { kind: ResponsePartKind.Error, error: builder.error }] + : builder.responseParts, usage: builder.usage, state, - ...(builder.error ? { error: builder.error } : {}), }; } diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 5fe04a14b95..89c5fea3049 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -75,12 +75,11 @@ const REPLAY_BUFFER_CAPACITY = 1000; const CLIENT_TOOL_CALL_DISCONNECT_TIMEOUT = 30_000; -/** - * Chat-level working-directory subsets are not yet operational in this build. - */ +/** Client actions whose state transition has no corresponding host-side behavior. */ const UNSUPPORTED_CLIENT_ACTION_TYPES: ReadonlySet = new Set([ ActionType.ChatWorkingDirectorySet, ActionType.ChatWorkingDirectoryRemoved, + ActionType.ChatTurnResume, ]); /** A client tool call in any of these statuses is still awaiting its result. */ diff --git a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts index 5b8ba8cb6be..db838166962 100644 --- a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts +++ b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts @@ -9,9 +9,10 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; import { ActionType, type ActionEnvelope, type ClientChangesetAction } from '../../common/state/sessionActions.js'; -import { ChangesetStatus, MessageKind, SessionLifecycle, SessionStatus, TerminalClaimKind, TerminalLifecycleStatus, TurnState, type AnnotationsState, type ChangesetState, type RootState, type SessionState, type SessionSummary, type TerminalState } from '../../common/state/protocol/state.js'; -import { buildDefaultChatUri, createChatState, createDefaultChatSummary, ROOT_STATE_URI, StateComponents, type ChatState } from '../../common/state/sessionState.js'; +import { ChangesetStatus, MessageKind, ResponsePartKind, SessionLifecycle, SessionStatus, TerminalClaimKind, TerminalLifecycleStatus, TurnState, type AnnotationsState, type ChangesetState, type ErrorInfo, type RootState, type SessionState, type SessionSummary, type TerminalState, type Turn } from '../../common/state/protocol/state.js'; +import { buildDefaultChatUri, createChatState, createDefaultChatSummary, getTurnError, ROOT_STATE_URI, StateComponents, type ChatState } from '../../common/state/sessionState.js'; import { AgentSubscriptionManager, ChangesetStateSubscription, ChatStateSubscription, isActionEnvelopeRelevantToSubscriptionUris, RootStateSubscription, SessionStateSubscription, TerminalStateSubscription } from '../../common/state/agentSubscription.js'; +import { normalizeLegacyActionEnvelope, readLegacyTurnError } from '../../common/state/legacyProtocolCompatibility.js'; // Helpers @@ -509,6 +510,83 @@ suite('ChatStateSubscription', () => { return disposables.add(new ChatStateSubscription(uri, clientId, () => ++seq, noop)); } + function makeLegacyErrorTurn(error: ErrorInfo): Turn & { readonly error: ErrorInfo } { + return { + id: 'turn-1', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + responseParts: [], + usage: undefined, + state: TurnState.Error, + error, + }; + } + + test('normalizes legacy live errors to durable response parts', () => { + const error: ErrorInfo = { errorType: 'LegacyError', message: 'legacy failure' }; + const envelope = normalizeLegacyActionEnvelope({ + channel: chatUri, + serverSeq: 1, + origin: undefined, + action: { + type: ActionType.ChatError, + turnId: 'turn-1', + duration: 1000, + error, + }, + }); + + assert.deepStrictEqual(envelope.action, { + type: ActionType.ChatError, + turnId: 'turn-1', + duration: 1000, + part: { kind: ResponsePartKind.Error, error }, + }); + }); + + test('normalizes legacy loaded turn errors to durable response parts', () => { + const error: ErrorInfo = { errorType: 'LegacyError', message: 'legacy failure' }; + const envelope = normalizeLegacyActionEnvelope({ + channel: chatUri, + serverSeq: 1, + origin: undefined, + action: { + type: ActionType.ChatTurnsLoaded, + turns: [makeLegacyErrorTurn(error)], + }, + }); + + assert.deepStrictEqual(envelope.action, { + type: ActionType.ChatTurnsLoaded, + turns: [{ + id: 'turn-1', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + responseParts: [{ kind: ResponsePartKind.Error, error }], + usage: undefined, + state: TurnState.Error, + }], + }); + }); + + test('normalizes legacy snapshot errors to durable response parts', () => { + const error: ErrorInfo = { errorType: 'LegacyError', message: 'legacy failure' }; + const legacyTurn = makeLegacyErrorTurn(error); + const sub = createSub(); + + sub.handleSnapshot(makeChatState(chatUri, undefined, { turns: [legacyTurn] }), 0); + + assert.deepStrictEqual({ + legacyError: getTurnError(legacyTurn), + error: getTurnError(sub.verifiedValue?.turns[0]), + responseParts: sub.verifiedValue?.turns[0].responseParts, + legacyField: sub.verifiedValue?.turns[0] && readLegacyTurnError(sub.verifiedValue.turns[0]), + }, { + legacyError: error, + error, + responseParts: [{ kind: ResponsePartKind.Error, error }], + legacyField: undefined, + }); + }); + test('server terminal turn action drops stale optimistic turn start', () => { const sub = createSub(); sub.handleSnapshot(makeChatState(chatUri), 0); diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 9bfd5a5e895..1aad43a8c80 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -585,7 +585,7 @@ suite('AgentHostStateManager', () => { type: ActionType.ChatError, turnId: 'turn-1', duration: 1000, - error: { errorType: 'failed', message: 'boom' }, + part: { kind: ResponsePartKind.Error, error: { errorType: 'failed', message: 'boom' } }, }); assert.deepStrictEqual(events, [ diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index 48d93ee9355..206a35d81ab 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -294,7 +294,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { devDeviceId: 'client-dev-device-id', }; startTurn('t-client', 'hello', undefined, defaultChatUri, clientContext); - fire({ type: ActionType.ChatError, turnId: 't-client', duration: 100, error: { errorType: 'providerFailed', message: 'failed' } }); + fire({ type: ActionType.ChatError, turnId: 't-client', duration: 100, part: { kind: ResponsePartKind.Error, error: { errorType: 'providerFailed', message: 'failed' } } }); assert.deepStrictEqual([completedEvents()[0], failedEvents()[0]].map(event => { const data = event.data as Record; @@ -560,7 +560,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { startTurn('turn-success'); fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-success', duration: 1000 }); startTurn('turn-error'); - fire({ type: ActionType.ChatError, turnId: 'turn-error', duration: 1000, error: { errorType: 'oops', message: 'fail' } }); + fire({ type: ActionType.ChatError, turnId: 'turn-error', duration: 1000, part: { kind: ResponsePartKind.Error, error: { errorType: 'oops', message: 'fail' } } }); startTurn('turn-cancelled'); fire({ type: ActionType.ChatTurnCancelled, turnId: 'turn-cancelled', duration: 1000 }); @@ -754,7 +754,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { test('emits result=error on ChatError', () => { setupSession(); startTurn('turn-1'); - fire({ type: ActionType.ChatError, turnId: 'turn-1', duration: 1000, error: { errorType: 'oops', message: 'fail' } }); + fire({ type: ActionType.ChatError, turnId: 'turn-1', duration: 1000, part: { kind: ResponsePartKind.Error, error: { errorType: 'oops', message: 'fail' } } }); const events = completedEvents(); assert.strictEqual(events.length, 1); @@ -769,14 +769,17 @@ suite('AgentSideEffects — turn tracker telemetry', () => { type: ActionType.ChatError, turnId: 'turn-1', duration: 1000, - error: { - errorType: 'quota', - message: 'quota exceeded', - _meta: { - chatError: { - fetchError: { - requestId: 'provider-request-id', - serverRequestId: 'service-request-id', + part: { + kind: ResponsePartKind.Error, + error: { + errorType: 'quota', + message: 'quota exceeded', + _meta: { + chatError: { + fetchError: { + requestId: 'provider-request-id', + serverRequestId: 'service-request-id', + }, }, }, }, @@ -811,7 +814,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { startTurn('subagent-complete', 'hello', undefined, subagentChatUri); fire({ type: ActionType.ChatTurnComplete, turnId: 'subagent-complete', duration: 1000 }, subagentChatUri); startTurn('subagent-failed', 'hello', undefined, subagentChatUri); - fire({ type: ActionType.ChatError, turnId: 'subagent-failed', duration: 1000, error: { errorType: 'oops', message: 'fail' } }, subagentChatUri); + fire({ type: ActionType.ChatError, turnId: 'subagent-failed', duration: 1000, part: { kind: ResponsePartKind.Error, error: { errorType: 'oops', message: 'fail' } } }, subagentChatUri); assert.deepStrictEqual({ completed: completedEvents().map(event => { diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index bd1a2513c2a..5a5d1008554 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -1161,7 +1161,7 @@ suite('AgentSideEffects', () => { const envelope = await error; assert.deepStrictEqual({ sendMessageCalls: agent.sendMessageCalls.length, - errorType: envelope.action.type === ActionType.ChatError ? envelope.action.error.errorType : undefined, + errorType: envelope.action.type === ActionType.ChatError ? envelope.action.part.error.errorType : undefined, }, { sendMessageCalls: 0, errorType: 'sendFailed', @@ -1200,7 +1200,7 @@ suite('AgentSideEffects', () => { const envelope = await error; assert.deepStrictEqual({ sendMessageCalls: agent.sendMessageCalls.length, - errorType: envelope.action.type === ActionType.ChatError ? envelope.action.error.errorType : undefined, + errorType: envelope.action.type === ActionType.ChatError ? envelope.action.part.error.errorType : undefined, }, { sendMessageCalls: 0, errorType: 'sendFailed', @@ -1589,7 +1589,7 @@ suite('AgentSideEffects', () => { await originalSendMessage(...args); agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), - action: { type: ActionType.ChatError, turnId: 'turn-1', duration: 1, error: { errorType: 'CodexMaterializeFailed', message: 'workspace root rejected' } }, + action: { type: ActionType.ChatError, turnId: 'turn-1', duration: 1, part: { kind: ResponsePartKind.Error, error: { errorType: 'CodexMaterializeFailed', message: 'workspace root rejected' } } }, }); agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), @@ -2371,7 +2371,7 @@ suite('AgentSideEffects', () => { agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), - action: { type: ActionType.ChatError, turnId: 'turn-1', duration: 1000, error: { errorType: 'Error', message: 'boom' } }, + action: { type: ActionType.ChatError, turnId: 'turn-1', duration: 1000, part: { kind: ResponsePartKind.Error, error: { errorType: 'Error', message: 'boom' } } }, }); assert.deepStrictEqual({ @@ -7125,7 +7125,7 @@ suite('AgentSideEffects', () => { agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), - action: { type: ActionType.ChatError, turnId: 'turn-1', duration: 100, error: { errorType: 'test', message: 'failed' } }, + action: { type: ActionType.ChatError, turnId: 'turn-1', duration: 100, part: { kind: ResponsePartKind.Error, error: { errorType: 'test', message: 'failed' } } }, }); agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), @@ -7153,7 +7153,7 @@ suite('AgentSideEffects', () => { agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), - action: { type: ActionType.ChatError, turnId: 'turn-1', duration: 100, error: { errorType: 'terminal', message: 'failed' } }, + action: { type: ActionType.ChatError, turnId: 'turn-1', duration: 100, part: { kind: ResponsePartKind.Error, error: { errorType: 'terminal', message: 'failed' } } }, }); await captured.p; diff --git a/src/vs/platform/agentHost/test/node/chatContributions.test.ts b/src/vs/platform/agentHost/test/node/chatContributions.test.ts index 56ed742d68f..217bf359d0a 100644 --- a/src/vs/platform/agentHost/test/node/chatContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/chatContributions.test.ts @@ -25,7 +25,7 @@ import { readAgentMessageDelegationMeta, toAgentMessageDelegationMeta } from '.. import { ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { ChatOriginKind } from '../../common/state/protocol/state.js'; -import { buildChatUri, buildDefaultChatUri, MessageKind, PendingMessageKind, SessionStatus, TurnState, type ISessionGitHubState, type Message, type PendingMessage, type Turn } from '../../common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus, TurnState, type ISessionGitHubState, type Message, type PendingMessage, type Turn } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostClientConnectionService, IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; import { AgentHostChatContributions } from '../../node/agentHostChatContributionsService.js'; @@ -967,7 +967,7 @@ suite('AgentHostChatContributions', () => { actions.push(envelope.action.type); } if (envelope.action.type === ActionType.ChatError) { - errorTypes.push(envelope.action.error.errorType); + errorTypes.push(envelope.action.part.error.errorType); } })); queue.clearAgent(); @@ -1452,7 +1452,7 @@ suite('AgentHostChatContributions', () => { type: ActionType.ChatError, turnId: 'first-turn', duration: 1, - error: reason.error, + part: { kind: ResponsePartKind.Error, error: reason.error }, }); } else { sideChat.stateManager.dispatchServerAction(sideChat.sideChat, { diff --git a/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts index 0e86edb50e7..affbe13bec0 100644 --- a/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts @@ -139,7 +139,7 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { const errorSignal = signals.find(s => s.kind === 'action' && s.action.type === ActionType.ChatError); assert.ok(errorSignal && errorSignal.kind === 'action' && errorSignal.action.type === ActionType.ChatError); assert.strictEqual(errorSignal.action.duration, 123); - const error = errorSignal.action.error; + const error = errorSignal.action.part.error; const meta = error._meta as { chatError?: { fetchError?: { type?: string } } } | undefined; assert.strictEqual(meta?.chatError?.fetchError?.type, 'quotaExceeded'); assert.ok(!error.message.includes(PROXY_ERROR_PREFIX), 'proxy marker should be stripped from the human-readable message'); @@ -159,7 +159,7 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { const errorSignal = signals.find(s => s.kind === 'action' && s.action.type === ActionType.ChatError); assert.ok(errorSignal && errorSignal.kind === 'action' && errorSignal.action.type === ActionType.ChatError); - const meta = errorSignal.action.error._meta as { chatError?: { fetchError?: { type?: string } } } | undefined; + const meta = errorSignal.action.part.error._meta as { chatError?: { fetchError?: { type?: string } } } | undefined; assert.strictEqual(meta?.chatError?.fetchError?.type, 'quotaExceeded'); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts b/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts index f60b37f5877..131df96fad0 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts @@ -1319,7 +1319,7 @@ suite('codexMapAppServerEvents', () => { }, }); assert.deepStrictEqual(actions, [ - { type: ActionType.ChatError, turnId: 'turn_a', duration: 0, error: { errorType: 'CodexError', message: 'boom' } }, + { type: ActionType.ChatError, turnId: 'turn_a', duration: 0, part: { kind: ResponsePartKind.Error, error: { errorType: 'CodexError', message: 'boom' } } }, { type: ActionType.ChatTurnComplete, turnId: 'turn_a', duration: 0 }, ]); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexReplayMapper.test.ts b/src/vs/platform/agentHost/test/node/codex/codexReplayMapper.test.ts index 6381b5d598e..59891621225 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexReplayMapper.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexReplayMapper.test.ts @@ -8,7 +8,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { readAgentMessageDelegationMeta } from '../../../common/meta/agentMessageDelegationMeta.js'; import { SessionServerToolName } from '../../../common/serverToolNames.js'; import { replayThreadToTurns } from '../../../node/codex/codexReplayMapper.js'; -import { MessageKind, ResponsePartKind, ToolCallStatus, ToolResultContentType, TurnState, type ModelSelection } from '../../../common/state/sessionState.js'; +import { getTurnError, MessageKind, ResponsePartKind, ToolCallStatus, ToolResultContentType, TurnState, type ModelSelection } from '../../../common/state/sessionState.js'; suite('codexReplayMapper', () => { @@ -475,7 +475,7 @@ suite('codexReplayMapper', () => { startedAt: null, completedAt: null, durationMs: null, }], } as never); - assert.deepStrictEqual(turns.map(turn => ({ state: turn.state, error: turn.error })), [{ + assert.deepStrictEqual(turns.map(turn => ({ state: turn.state, error: getTurnError(turn) })), [{ state: TurnState.Error, error: { errorType: 'CodexError', message: 'oops' }, }]); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index ab54f65b41d..4978a4d47c8 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -7405,20 +7405,23 @@ suite('CopilotAgentSession', () => { assert.ok(isAction(signals[0], ActionType.ChatError)); if (isAction(signals[0], ActionType.ChatError)) { const action = signals[0].action as ChatErrorAction; - assert.deepStrictEqual(action.error, { - errorType: 'TestError', - message: 'something went wrong', - stack: 'Error: something went wrong', - _meta: { - chatError: { - fetchError: { - type: 'failed', - reason: 'something went wrong', - requestId: 'provider-request-id', - serverRequestId: 'service-request-id', - capiError: { - code: 'test-code', - message: 'something went wrong', + assert.deepStrictEqual(action.part, { + kind: ResponsePartKind.Error, + error: { + errorType: 'TestError', + message: 'something went wrong', + stack: 'Error: something went wrong', + _meta: { + chatError: { + fetchError: { + type: 'failed', + reason: 'something went wrong', + requestId: 'provider-request-id', + serverRequestId: 'service-request-id', + capiError: { + code: 'test-code', + message: 'something went wrong', + }, }, }, }, diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts index 91c80ad52af..1a820aa77ef 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts @@ -584,7 +584,7 @@ async function driveTurn(c: TestProtocolClient, chat: string, turnId: string, cl if (isActionNotification(notification, 'chat/error')) { const action = getActionEnvelope(notification).action as ChatErrorAction; - throw new Error(`Session error while driving ${turnId}: ${action.error.errorType}: ${action.error.message}`); + throw new Error(`Session error while driving ${turnId}: ${action.part.error.errorType}: ${action.part.error.message}`); } if (isActionNotification(notification, 'chat/toolCallReady')) { diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/ahpSnapshot.ts b/src/vs/platform/agentHost/test/node/e2e/harness/ahpSnapshot.ts index b5ab709da89..e19d5664513 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/ahpSnapshot.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/ahpSnapshot.ts @@ -426,8 +426,8 @@ function projectAction( type: action.type, turnId: normalizeIdentifier(action.turnId, 'turn', turns), error: { - errorType: action.error.errorType, - message: action.error.message, + errorType: action.part.error.errorType, + message: action.part.error.message, }, } : { type: action.type }; case ActionType.ChatUsage: @@ -707,7 +707,7 @@ async function bindPrerequisites( if (replayError) { throw replayError; } - throw new Error(`[ahp-snapshot] turn failed before chat/toolCallReady: ${readyAction.error.errorType}: ${readyAction.error.message}`); + throw new Error(`[ahp-snapshot] turn failed before chat/toolCallReady: ${readyAction.part.error.errorType}: ${readyAction.part.error.message}`); } if (readyAction.type !== ActionType.ChatToolCallReady) { throw new Error('[ahp-snapshot] expected chat/toolCallReady prerequisite'); @@ -925,7 +925,7 @@ async function waitForFinalServerMessage(client: IAhpSnapshotClient, entries: re if (replayError) { throw replayError; } - throw new Error(`[ahp-snapshot] round failed before ${finalActionType}: ${action.error.errorType}: ${action.error.message}`); + throw new Error(`[ahp-snapshot] round failed before ${finalActionType}: ${action.part.error.errorType}: ${action.part.error.message}`); } } } diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts b/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts index a5a08cf2ff0..4cb37e8e9f3 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts @@ -31,7 +31,7 @@ import { join } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; import { CollectAgentHostDebugLogsExtensionMethod, type IAgentHostExtensionCommandMap } from '../../../../common/agentHostExtensionProtocol.js'; import { readToolCallMeta } from '../../../../common/meta/agentToolCallMeta.js'; -import { MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ROOT_STATE_URI, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, buildDefaultChatUri, getInlineToolInput, type MessageAttachment } from '../../../../common/state/sessionState.js'; +import { MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ROOT_STATE_URI, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, buildDefaultChatUri, getInlineToolInput, getTurnError, type MessageAttachment } from '../../../../common/state/sessionState.js'; import { ActionType, type ChatErrorAction, type ChatToolCallCompleteAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatUsageAction } from '../../../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; import { @@ -179,7 +179,7 @@ suite('Agent Host E2E — Copilot (Copilot-specific)', function () { && getActionEnvelope(notification).channel === chatUri, 90_000, ); - const liveError = (getActionEnvelope(liveNotification).action as ChatErrorAction).error; + const liveError = (getActionEnvelope(liveNotification).action as ChatErrorAction).part.error; client = await lease.restart(); client.setWorkingDirectory(workingDirectory); @@ -194,7 +194,7 @@ suite('Agent Host E2E — Copilot (Copilot-specific)', function () { const restoredTurn = reopened.turns.find(turn => turn.message.text === prompt); assert.deepStrictEqual({ state: restoredTurn?.state, - error: restoredTurn?.error, + error: getTurnError(restoredTurn), }, { state: TurnState.Error, error: liveError, @@ -722,7 +722,7 @@ suite('Agent Host E2E — Copilot (Copilot-specific)', function () { ); if (isActionNotification(next, 'chat/error')) { const action = getActionEnvelope(next).action as ChatErrorAction; - throw new Error(`cd-strip turn failed: ${JSON.stringify(action.error)}`); + throw new Error(`cd-strip turn failed: ${JSON.stringify(action.part.error)}`); } if (isActionNotification(next, 'chat/turnComplete')) { break; diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/copilotCoverageSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/copilotCoverageSuite.ts index 2a7bac3ea25..143414e3790 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/copilotCoverageSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/copilotCoverageSuite.ts @@ -130,7 +130,7 @@ export function defineCopilotCoverageTests(context: IAgentHostE2ETestContext): v seen.add(notification as object); if (isActionNotification(notification, 'chat/error')) { const action = getActionEnvelope(notification).action as ChatErrorAction; - throw new Error(`Tool-search turn failed: ${action.error.errorType}: ${action.error.message}`); + throw new Error(`Tool-search turn failed: ${action.part.error.errorType}: ${action.part.error.message}`); } if (isActionNotification(notification, 'chat/toolCallStart')) { const action = getActionEnvelope(notification).action as ChatToolCallStartAction; diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/coreSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/coreSuite.ts index 4721eb2960e..a580514aa75 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/coreSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/coreSuite.ts @@ -707,11 +707,15 @@ export function defineCoreTests(context: IAgentHostE2ETestContext): void { && (getActionEnvelope(n).action as { readonly turnId: string }).turnId === turnId, 30_000, ); - const action = getActionEnvelope(failed).action as { readonly error: { readonly errorType: string; readonly message: string } }; + const action = getActionEnvelope(failed).action; + assert.strictEqual(action.type, ActionType.ChatError); + if (action.type !== ActionType.ChatError) { + return; + } assert.deepStrictEqual({ - errorType: action.error.errorType, - mentionsModel: /model/i.test(action.error.message), + errorType: action.part.error.errorType, + mentionsModel: /model/i.test(action.part.error.message), }, { errorType: config.provider === 'copilotcli' ? 'sendFailed' : config.provider === 'claude' ? 'success' : 'modelSelectionFailed', mentionsModel: true, diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts index 235993f170c..21fd8fdc1c4 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts @@ -218,7 +218,7 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void { seen.add(notification as object); if (isActionNotification(notification, 'chat/error')) { const action = getActionEnvelope(notification).action as ChatErrorAction; - throw new Error(`Peer chat error during ${turnId}: ${JSON.stringify(action.error)}`); + throw new Error(`Peer chat error during ${turnId}: ${JSON.stringify(action.part.error)}`); } if (isActionNotification(notification, 'chat/turnComplete')) { break; diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts index a1a8419ac81..cd75e5a93f8 100644 --- a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts @@ -8,7 +8,7 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { readToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; import { AgentSession } from '../../common/agent.js'; -import { MessageAttachmentKind, MessageKind, ResponsePartKind, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, type ResponsePart, type StringOrMarkdown, type ToolCallResponsePart, type ToolResultContent } from '../../common/state/sessionState.js'; +import { getTurnError, MessageAttachmentKind, MessageKind, ResponsePartKind, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, type ResponsePart, type StringOrMarkdown, type ToolCallResponsePart, type ToolResultContent } from '../../common/state/sessionState.js'; import { appendSdkToolResultContent, mapSessionEvents as mapSessionEventsWithRouting, type IMapSessionEventsOptions } from '../../node/copilot/mapSessionEvents.js'; import { toSessionEvents, type ISessionEvent } from './copilotTestEvents.js'; @@ -752,7 +752,7 @@ suite('mapSessionEvents — history replay', () => { id: turn.id, state: turn.state, duration: turn.duration, - error: turn.error, + error: getTurnError(turn), parts: partKinds(turn.responseParts), })), [{ id: 'user-event', @@ -780,6 +780,7 @@ suite('mapSessionEvents — history replay', () => { parts: [ { kind: ResponsePartKind.Markdown, content: 'Working on it.' }, { kind: ResponsePartKind.Markdown, content: 'Late completion.' }, + { kind: ResponsePartKind.Error }, ], }]); }); @@ -1048,9 +1049,9 @@ suite('mapSessionEvents — subagent routing', () => { assert.deepStrictEqual({ parentState: turns[0].state, - parentError: turns[0].error, + parentError: getTurnError(turns[0]), subagentState: subagentTurn?.state, - subagentError: subagentTurn?.error, + subagentError: getTurnError(subagentTurn), subagentParts: partKinds(subagentTurn?.responseParts ?? []), }, { parentState: TurnState.Complete, @@ -1073,6 +1074,7 @@ suite('mapSessionEvents — subagent routing', () => { }, subagentParts: [ { kind: ResponsePartKind.Markdown, content: 'Partial result.' }, + { kind: ResponsePartKind.Error }, ], }); }); diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 0fefd0ab832..ff06d4df868 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -1219,7 +1219,7 @@ function _idle(session: URI, sessionStr: string, turnId: string): IAgentActionSi /** Creates a {@link ActionType.ChatError} signal. */ function _error(session: URI, sessionStr: string, turnId: string, errorType: string, message: string, stack?: string): IAgentActionSignal { - return _action(session, { type: ActionType.ChatError, turnId, duration: 1, error: { errorType, message, stack } }); + return _action(session, { type: ActionType.ChatError, turnId, duration: 1, part: { kind: ResponsePartKind.Error, error: { errorType, message, stack } } }); } /** Creates a {@link ActionType.SessionTitleChanged} signal. */ diff --git a/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts index c69187668fa..b7bd94c7aa5 100644 --- a/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts @@ -94,7 +94,7 @@ suite('Protocol WebSocket — Turn Execution', function () { const errorNotif = await client.waitForNotification(n => isActionNotification(n, 'chat/error')); const errorAction = getActionEnvelope(errorNotif).action; if (errorAction.type === 'chat/error') { - assert.strictEqual(errorAction.error.message, 'Something went wrong'); + assert.strictEqual(errorAction.part.error.message, 'Something went wrong'); } }); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 5c05c5b69ff..6b58d448fb2 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -19,7 +19,7 @@ import { type IAgentCreateChatRequestOptions, type IAgentCreateSessionConfig, ty import { type IAgentHostManagedSettingsDiagnostics, type IAgentHostNetworkDiagnosticsInfo, type IAgentHostNetworkFetchResult, type IAgentService } from '../../common/agentService.js'; import { ChatSourceKind, CompletionsParams, CompletionsResult, ContentEncoding, ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult, ResourceMkdirParams, ResourceMkdirResult, ResourceResolveParams, ResourceResolveResult, ResourceCopyParams, ResourceCopyResult } from '../../common/state/protocol/commands.js'; import type { Implementation } from '../../common/state/protocol/common/commands.js'; -import { ActionType, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction, type ProgressParams } from '../../common/state/sessionActions.js'; +import { ActionType, type ActionEnvelope, type ChatAction, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction, type ProgressParams } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, JsonRpcErrorCodes, ProtocolError, AhpErrorCodes, AHP_UNSUPPORTED_PROTOCOL_VERSION, AHP_SESSION_NOT_FOUND, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; import { MessageKind, ResponsePartKind, SessionStatus, ChangesetStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, readSessionExternal, readSessionWorkspaceless, withSessionExternal, withSessionWorkspaceless, type SessionSummary } from '../../common/state/sessionState.js'; @@ -888,17 +888,18 @@ suite('ProtocolServerHandler', () => { assert.strictEqual(envelope.origin.clientSeq, 1); }); - test('unsupported chat working-directory actions are rejected, not dispatched', () => { + test('unsupported chat actions are rejected, not dispatched', () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); - const cases: readonly { readonly type: ActionType; readonly channel: string }[] = [ - { type: ActionType.ChatWorkingDirectorySet, channel: defaultChatUri }, - { type: ActionType.ChatWorkingDirectoryRemoved, channel: defaultChatUri }, + const cases: readonly { readonly action: ChatAction; readonly channel: string }[] = [ + { action: { type: ActionType.ChatWorkingDirectorySet, directory: 'file:///tmp/extra-root' }, channel: defaultChatUri }, + { action: { type: ActionType.ChatWorkingDirectoryRemoved, directory: 'file:///tmp/extra-root' }, channel: defaultChatUri }, + { action: { type: ActionType.ChatTurnResume, turnId: 'turn-1' }, channel: defaultChatUri }, ]; - for (const [index, { type, channel }] of cases.entries()) { - const clientId = `wd-client-${index}`; + for (const [index, { action, channel }] of cases.entries()) { + const clientId = `unsupported-client-${index}`; const clientSeq = 100 + index; const transport = connectClient(clientId, [sessionUri, defaultChatUri]); transport.sent.length = 0; @@ -907,20 +908,20 @@ suite('ProtocolServerHandler', () => { transport.simulateMessage(notification('dispatchAction', { channel, clientSeq, - action: { type, directory: 'file:///tmp/extra-root' }, + action, })); // No dispatch: the gate intercepts before reaching the agent service, // so the reducer never runs and synchronized state is untouched. - assert.deepStrictEqual(agentService.handledActions, [], `${type} must not be dispatched`); + assert.deepStrictEqual(agentService.handledActions, [], `${action.type} must not be dispatched`); // Exactly one rejection envelope, preserving the original origin so the // client can reconcile its optimistic action. const actionMsgs = findNotifications(transport.sent, 'action'); - assert.strictEqual(actionMsgs.length, 1, `${type} should emit exactly one envelope`); + assert.strictEqual(actionMsgs.length, 1, `${action.type} should emit exactly one envelope`); const envelope = actionMsgs[0].params as unknown as { action: { type: string }; origin: { clientId: string; clientSeq: number }; rejectionReason?: string }; - assert.strictEqual(envelope.action.type, type); - assert.ok(envelope.rejectionReason, `${type} envelope should carry a rejectionReason`); + assert.strictEqual(envelope.action.type, action.type); + assert.ok(envelope.rejectionReason, `${action.type} envelope should carry a rejectionReason`); assert.strictEqual(envelope.origin.clientId, clientId); assert.strictEqual(envelope.origin.clientSeq, clientSeq); } diff --git a/src/vs/platform/agentHost/test/node/reducers.test.ts b/src/vs/platform/agentHost/test/node/reducers.test.ts index a44e41c1abe..805a20ec8ee 100644 --- a/src/vs/platform/agentHost/test/node/reducers.test.ts +++ b/src/vs/platform/agentHost/test/node/reducers.test.ts @@ -104,7 +104,6 @@ suite('chatReducer – summaryStatus with tool call confirmations and input requ responseParts: [], usage: undefined, state: TurnState.Complete, - error: undefined, }); }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 9bb962ccc26..102fc924bd6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -48,7 +48,7 @@ import { ConfirmationOptionKind, CustomizationType, JsonPrimitive, McpServerAuth import { compareProtocolVersions } from '../../../../../../platform/agentHost/common/state/protocol/version/registry.js'; import { ActionType, ChatTurnStartedAction, isChatAction, type ClientChatAction, type ClientSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, AHP_NOT_FOUND, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChatOriginKind, getInlineToolInput, getToolSubagentContent, isChatReadOnly, isDefaultChatUri, isMessageHiddenFromTranscript, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionStatus, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, readSessionWorkspaceless, readUsageInfoMeta, withMessageHiddenFromTranscript, type ChatState, type ISessionWithDefaultChat, type ICompletedToolCall, type InputRequestResponsePart, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageChatAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type PendingMessage, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type ChatSummary, type SessionState, type StringOrMarkdown, type ToolCallResponsePart, type ToolCallState, type ToolInput, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChatOriginKind, getInlineToolInput, getToolSubagentContent, getTurnError, isChatReadOnly, isDefaultChatUri, isMessageHiddenFromTranscript, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionStatus, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, readSessionWorkspaceless, readUsageInfoMeta, withMessageHiddenFromTranscript, type ChatState, type ISessionWithDefaultChat, type ICompletedToolCall, type InputRequestResponsePart, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageChatAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type PendingMessage, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type ChatSummary, type SessionState, type StringOrMarkdown, type ToolCallPendingConfirmationState, type ToolCallResponsePart, type ToolCallRunningState, type ToolCallState, type ToolInput, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -140,6 +140,12 @@ interface IRestoredSubagentState extends IDisposable { getState(): ISessionWithDefaultChat | undefined; } +type ClientToolExecutionRequest = Omit & { + readonly toolCall: ToolCallRunningState | ToolCallPendingConfirmationState; +}; + +type ObservedSessionInputRequest = Exclude | ClientToolExecutionRequest; + type AgentHostInvocationFailedEvent = { requestId: string; provider: string; @@ -1869,11 +1875,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * error was forwarded in `_meta`. */ private _getTurnErrorDetails(turn: Turn | undefined): IChatResponseErrorDetails | undefined { - if (turn?.state !== TurnState.Error || !turn.error) { + const error = getTurnError(turn); + if (!error) { return undefined; } - return getChatErrorDetailsFromMeta(turn.error, this._chatErrorContext()) - ?? { message: localize('agentHost.turnError', "Error: ({0}) {1}", turn.error.errorType, turn.error.message) }; + return getChatErrorDetailsFromMeta(error, this._chatErrorContext()) + ?? { message: localize('agentHost.turnError', "Error: ({0}) {1}", error.errorType, error.message) }; } /** @@ -2359,7 +2366,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // Requests that we own should be 'invoked' when pending confirmation immediately because // we handle showing their UI directly. For simplicity in later tool call flows, rewrite them. const requests = derivedOpts({ equalsFn: equals }, reader => - (state.read(reader)?.inputNeeded ?? []).map((request): SessionInputRequest => { + (state.read(reader)?.inputNeeded ?? []).map((request): ObservedSessionInputRequest => { if (request.kind === SessionInputRequestKind.ToolConfirmation && request.toolCall.status === ToolCallStatus.PendingConfirmation && request.toolCall.contributor?.kind === ToolCallContributorKind.Client) { @@ -2367,6 +2374,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC ...request, kind: SessionInputRequestKind.ToolClientExecution, clientId: request.toolCall.contributor.clientId, + toolCall: request.toolCall, }; } return request; @@ -2436,8 +2444,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } }); let generation = 0; - let observedRequest: SessionToolClientExecutionRequest | undefined; - let startedRequest: SessionToolClientExecutionRequest | undefined; + let observedRequest: ClientToolExecutionRequest | undefined; + let startedRequest: ClientToolExecutionRequest | undefined; let invocationStarted = false; const unobservedTimer = itemStore.add(new MutableDisposable()); itemStore.add(autorun(reader => { @@ -2625,7 +2633,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * attribute to that observer's chat. Without it the tool runs headlessly, * independent of whether the owning turn is live. */ - private async _executeClientTool(request: SessionToolClientExecutionRequest, contextSessionResource: URI | undefined, token: CancellationToken, isCurrent: () => boolean, markInvocationStarted: () => void): Promise { + private async _executeClientTool(request: ClientToolExecutionRequest, contextSessionResource: URI | undefined, token: CancellationToken, isCurrent: () => boolean, markInvocationStarted: () => void): Promise { const chatURI = request.chat.toString(); const toolCall = request.toolCall; const toolName = toolCall.toolName; @@ -2747,7 +2755,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * answer it, so report a failed completion rather than pop a headless * modal. */ - private _denyClientTool(request: SessionToolClientExecutionRequest): void { + private _denyClientTool(request: ClientToolExecutionRequest): void { const toolCall = request.toolCall; this._logService.warn(`[AgentHost] Denying client tool ${toolCall.toolName} (callId=${toolCall.toolCallId}): it can request confirmation but no session claimed it within ${UNOBSERVED_CLIENT_TOOL_GRACE_MS}ms`); this._resolveToolCall(request.chat.toString(), request.turnId, toolCall.toolCallId, { @@ -3365,11 +3373,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (!seenActive) { return; } - if (!opts.suppressErrorMarkdown && lastTurn?.state === TurnState.Error && lastTurn.error) { - const forwarded = getChatErrorDetailsFromMeta(lastTurn.error, this._chatErrorContext()); + const turnError = getTurnError(lastTurn); + if (!opts.suppressErrorMarkdown && turnError) { + const forwarded = getChatErrorDetailsFromMeta(turnError, this._chatErrorContext()); const content = forwarded ? new MarkdownString(`\n\n${forwarded.message}`) - : new MarkdownString(`\n\nError: (${lastTurn.error.errorType}) ${lastTurn.error.message}`); + : new MarkdownString(`\n\nError: (${turnError.errorType}) ${turnError.message}`); opts.sink([{ kind: 'markdownContent', content }]); } finish(lastTurn); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/importLocalConversationToAgentSession.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/importLocalConversationToAgentSession.ts index 0d7a4ebf499..338e1572fc7 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/importLocalConversationToAgentSession.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/importLocalConversationToAgentSession.ts @@ -229,6 +229,9 @@ export function importedTurnsFromChatModel(model: IChatModel): Turn[] { for (const request of model.getRequests()) { const responseParts = responsePartsFromRequest(request); const outcome = turnOutcomeFromRequest(request); + if (outcome.error) { + responseParts.push({ kind: ResponsePartKind.Error, error: outcome.error }); + } if (request.isSystemInitiated) { // Not a genuine user message; append its output to the previous // turn so the agent's continued work is preserved without surfacing @@ -239,7 +242,6 @@ export function importedTurnsFromChatModel(model: IChatModel): Turn[] { if (previous) { previous.responseParts.push(...responseParts); previous.state = outcome.state; - previous.error = outcome.error; } continue; } @@ -249,9 +251,7 @@ export function importedTurnsFromChatModel(model: IChatModel): Turn[] { responseParts, usage: undefined, state: outcome.state, - ...(outcome.error ? { error: outcome.error } : {}), } satisfies Turn); } return turns; } - diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index aef51709cad..729aea89d1f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -13,7 +13,7 @@ import { Schemas } from '../../../../../../base/common/network.js'; import { posix, win32 } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; -import { buildSubagentChatUri, isMessageHiddenFromTranscript, MessageKind, parseChatUri, ToolCallCancellationReason, ToolCallContributorKind, ToolCallRiskAssessmentStatus, ToolCallStatus, TurnState, ResponsePartKind, getInlineToolInput, getToolFileEdits, getToolOutputText, getToolSubagentContent, hasReportedUsage, readUsageInfoMeta, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, type ActiveTurn, type ChatInputAnswer, type ChatInputRequest, type ICompletedToolCall, type InputRequestResponsePart, type Message, type TerminalCommandResult, type ToolCallPendingConfirmationState, type ToolCallState, type ToolResultSubagentContent, type Turn, FileEditKind, ToolResultContentType, type ToolResultContent, type UsageInfo, type UsageInfoMeta } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentChatUri, getTurnError, isMessageHiddenFromTranscript, MessageKind, parseChatUri, ToolCallCancellationReason, ToolCallContributorKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ResponsePartKind, getInlineToolInput, getToolFileEdits, getToolOutputText, getToolSubagentContent, hasReportedUsage, readUsageInfoMeta, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, type ActiveTurn, type ChatInputAnswer, type ChatInputRequest, type ICompletedToolCall, type InputRequestResponsePart, type Message, type TerminalCommandResult, type ToolCallPendingConfirmationState, type ToolCallState, type ToolResultSubagentContent, type Turn, FileEditKind, ToolResultContentType, type ToolResultContent, type UsageInfo, type UsageInfoMeta } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import type { ChatInputRequestWithPlanReview, IAgentHostPlanReview } from '../../../../../../platform/agentHost/common/agentHostPlanReview.js'; import { getToolKind } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { readToolCallMeta } from '../../../../../../platform/agentHost/common/meta/agentToolCallMeta.js'; @@ -960,9 +960,10 @@ export function turnsToHistory(backendSession: URI, turns: readonly Turn[], part // proper error — including the quota-exceeded upgrade affordance — // consistently with the live agent result. let errorDetails: IChatResponseErrorDetails | undefined; - if (turn.state === TurnState.Error && turn.error) { - errorDetails = getChatErrorDetailsFromMeta(turn.error, errorContext) - ?? { message: `Error: (${turn.error.errorType}) ${turn.error.message}` }; + const turnError = getTurnError(turn); + if (turnError) { + errorDetails = getChatErrorDetailsFromMeta(turnError, errorContext) + ?? { message: `Error: (${turnError.errorType}) ${turnError.message}` }; } const startedAt = turn.startedAt === undefined ? undefined : Date.parse(turn.startedAt); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index d2d92a352ac..74dec64161b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -125,8 +125,14 @@ type TestActionEnvelope = Omit & { action: SessionActi function normalizeTestAction(action: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): SessionAction | AgentHostChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction { if (hasKey(action, { endedAt: true })) { - const { endedAt: _endedAt, ...rest } = action as ILegacyTimedChatAction; - return { ...rest, duration: 1000 } as AgentHostChatAction; + if (action.type === 'chat/error') { + return { type: ActionType.ChatError, turnId: action.turnId, duration: 1000, part: { kind: ResponsePartKind.Error, error: action.error } }; + } + return { + type: action.type === 'chat/turnComplete' ? ActionType.ChatTurnComplete : ActionType.ChatTurnCancelled, + turnId: action.turnId, + duration: 1000, + }; } return action as SessionAction | AgentHostChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/importLocalConversationToAgentSession.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/importLocalConversationToAgentSession.test.ts index fa17e867392..4311ef3edff 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/importLocalConversationToAgentSession.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/importLocalConversationToAgentSession.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { ResponsePartKind, ToolResultContentType, TurnState, type ResponsePart, type ToolCallCompletedState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { getTurnError, ResponsePartKind, ToolResultContentType, TurnState, type ResponsePart, type ToolCallCompletedState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import type { IChatProgressResponseContent, IChatModel, IChatRequestModel, IChatResponseModel } from '../../../common/model/chatModel.js'; import { importedTurnsFromChatModel } from '../../../browser/agentSessions/agentHost/importLocalConversationToAgentSession.js'; @@ -72,8 +72,8 @@ suite('importedTurnsFromChatModel', () => { return importedTurnsFromChatModel(model).map(turn => ({ text: turn.message.text, state: turn.state, - error: turn.error, - parts: turn.responseParts.map(part => + error: getTurnError(turn), + parts: turn.responseParts.filter(part => part.kind !== ResponsePartKind.Error).map(part => part.kind === ResponsePartKind.Markdown || part.kind === ResponsePartKind.Reasoning ? { kind: part.kind, content: part.content } : { kind: part.kind, subagent: subagentOf(part) }), diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 75d969f3eb8..5bcd41ec7b0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -1143,7 +1143,7 @@ suite('stateToProgressAdapter', () => { test('error turn produces error details in history', () => { const turn = createTurn({ state: TurnState.Error, - error: { errorType: 'test', message: 'boom' }, + responseParts: [{ kind: ResponsePartKind.Error, error: { errorType: 'test', message: 'boom' } }], }); const history = turnsToHistory(URI.file('/'), [turn], 'p'); @@ -1157,11 +1157,14 @@ suite('stateToProgressAdapter', () => { test('forwarded quota error turn produces quota-exceeded error details', () => { const turn = createTurn({ state: TurnState.Error, - error: { - errorType: 'quota', - message: 'raw', - _meta: { chatError: { fetchError: { type: 'quotaExceeded', capiError: { code: 'quota_exceeded' } } } }, - }, + responseParts: [{ + kind: ResponsePartKind.Error, + error: { + errorType: 'quota', + message: 'raw', + _meta: { chatError: { fetchError: { type: 'quotaExceeded', capiError: { code: 'quota_exceeded' } } } }, + }, + }], }); const history = turnsToHistory(URI.file('/'), [turn], 'p'); From da59bf0201142bc91365a5512dfe18bd48a813d3 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 25 Aug 2026 15:33:46 -0700 Subject: [PATCH 022/116] chat: verify exact plugin commit checkouts (#332643) * chat: verify exact plugin commit checkouts Use a dedicated checkout operation for plugin sources that specify a commit SHA.\n\n- Resolve each full SHA to a commit object before changing the worktree.\n- Compare the resolved object ID with the requested SHA and check out the canonical ID.\n- Verify HEAD after native checkout and apply equivalent browser cache checks.\n- Add tests for SHA-shaped branch names, case normalization, HEAD checks, and update routing.\n\n(Commit message generated by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * git: localize exact checkout errors Use localized messages for errors from exact commit checkout. - Localize invalid SHA errors. - Localize resolved commit mismatch errors. - Localize checked-out HEAD mismatch errors. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/git/common/localGitService.ts | 1 + src/vs/platform/git/node/localGitService.ts | 21 ++++++- .../git/test/node/localGitService.test.ts | 61 +++++++++++++++++++ .../chat/browser/pluginGitCommandService.ts | 58 ++++++++++++++---- .../contrib/chat/browser/pluginSources.ts | 2 +- .../chat/common/plugins/pluginGitService.ts | 1 + .../pluginGitCommandService.ts | 4 ++ .../browser/pluginGitCommandService.test.ts | 24 ++++++++ .../agentPluginRepositoryService.test.ts | 5 +- .../pluginGitCommandService.test.ts | 15 +++++ 10 files changed, 176 insertions(+), 16 deletions(-) diff --git a/src/vs/platform/git/common/localGitService.ts b/src/vs/platform/git/common/localGitService.ts index e007f8bc2a2..58a397bf180 100644 --- a/src/vs/platform/git/common/localGitService.ts +++ b/src/vs/platform/git/common/localGitService.ts @@ -22,6 +22,7 @@ export interface ILocalGitService { clone(operationId: string, cloneUrl: string, targetPath: string, ref?: string): Promise; pull(operationId: string, repoPath: string, options?: IGitPullOptions): Promise; checkout(operationId: string, repoPath: string, treeish: string, detached?: boolean): Promise; + checkoutCommit(operationId: string, repoPath: string, commit: string): Promise; revParse(repoPath: string, ref: string): Promise; fetch(operationId: string, repoPath: string): Promise; revListCount(repoPath: string, fromRef: string, toRef: string): Promise; diff --git a/src/vs/platform/git/node/localGitService.ts b/src/vs/platform/git/node/localGitService.ts index ff0e4b1ab0a..c3c782ad7f8 100644 --- a/src/vs/platform/git/node/localGitService.ts +++ b/src/vs/platform/git/node/localGitService.ts @@ -6,8 +6,9 @@ import * as cp from 'child_process'; import { CancellationError } from '../../../base/common/errors.js'; import { generateUuid } from '../../../base/common/uuid.js'; -import { IGitPullOptions, ILocalGitService } from '../common/localGitService.js'; +import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; +import { IGitPullOptions, ILocalGitService } from '../common/localGitService.js'; export class LocalGitService implements ILocalGitService { declare readonly _serviceBrand: undefined; @@ -137,6 +138,24 @@ export class LocalGitService implements ILocalGitService { await this._exec(operationId, args, repoPath); } + async checkoutCommit(operationId: string, repoPath: string, commit: string): Promise { + const expectedCommit = commit.trim().toLowerCase(); + if (!/^[0-9a-f]{40}$/.test(expectedCommit)) { + throw new Error(localize('pluginsInvalidPinnedCommit', "Pinned plugin commit '{0}' is not a full SHA-1 hash.", commit)); + } + + const resolvedCommit = (await this._exec(operationId, ['rev-parse', `${expectedCommit}^{commit}`], repoPath)).trim().toLowerCase(); + if (resolvedCommit !== expectedCommit) { + throw new Error(localize('pluginsPinnedCommitResolutionMismatch', "Pinned plugin commit '{0}' resolved to a different commit '{1}'.", commit, resolvedCommit)); + } + + await this._exec(operationId, ['checkout', '--detach', resolvedCommit], repoPath); + const checkedOutCommit = (await this._exec(operationId, ['rev-parse', 'HEAD'], repoPath)).trim().toLowerCase(); + if (checkedOutCommit !== expectedCommit) { + throw new Error(localize('pluginsPinnedCommitCheckoutMismatch', "Pinned plugin commit '{0}' was not checked out. The repository is at commit '{1}'.", commit, checkedOutCommit)); + } + } + async revParse(repoPath: string, ref: string): Promise { return (await this._exec(generateUuid(), ['rev-parse', ref], repoPath)).trim(); } diff --git a/src/vs/platform/git/test/node/localGitService.test.ts b/src/vs/platform/git/test/node/localGitService.test.ts index e3007554424..e035dd87421 100644 --- a/src/vs/platform/git/test/node/localGitService.test.ts +++ b/src/vs/platform/git/test/node/localGitService.test.ts @@ -5,6 +5,10 @@ import assert from 'assert'; import * as cp from 'child_process'; +import { promises as fs } from 'fs'; +import { tmpdir } from 'os'; +import { promisify } from 'util'; +import { join } from '../../../../base/common/path.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { LocalGitService } from '../../node/localGitService.js'; @@ -46,8 +50,14 @@ function createPullError(message: string, stderr: string, code = 128): cp.ExecFi suite('LocalGitService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + const temporaryDirectories: string[] = []; + const execFile = promisify(cp.execFile); void store; + teardown(async () => { + await Promise.all(temporaryDirectories.splice(0).map(directory => fs.rm(directory, { recursive: true, force: true }))); + }); + test('pull runs ff-only for normal updates', async () => { const expectations: IExecFileExpectation[] = [ { args: ['rev-parse', 'HEAD'], stdout: 'aaaa\n' }, @@ -181,4 +191,55 @@ suite('LocalGitService', () => { ); assert.strictEqual(expectations.length, 0); }); + + test('checkoutCommit accepts uppercase SHA and verifies HEAD', async () => { + const expectedCommit = 'AABBCCDDEEFF00112233445566778899AABBCCDD'; + const normalizedCommit = expectedCommit.toLowerCase(); + const expectations: IExecFileExpectation[] = [ + { args: ['rev-parse', `${normalizedCommit}^{commit}`], stdout: `${normalizedCommit}\n` }, + { args: ['checkout', '--detach', normalizedCommit] }, + { args: ['rev-parse', 'HEAD'], stdout: `${normalizedCommit}\n` }, + ]; + const service = new LocalGitService(new NullLogService(), createExecFile(expectations)); + + await service.checkoutCommit('test-op', 'C:\\repo', expectedCommit); + + assert.strictEqual(expectations.length, 0); + }); + + test('checkoutCommit rejects when HEAD differs after checkout', async () => { + const expectedCommit = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const checkedOutCommit = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + const expectations: IExecFileExpectation[] = [ + { args: ['rev-parse', `${expectedCommit}^{commit}`], stdout: `${expectedCommit}\n` }, + { args: ['checkout', '--detach', expectedCommit] }, + { args: ['rev-parse', 'HEAD'], stdout: `${checkedOutCommit}\n` }, + ]; + const service = new LocalGitService(new NullLogService(), createExecFile(expectations)); + + await assert.rejects(() => service.checkoutCommit('test-op', 'C:\\repo', expectedCommit), /was not checked out/); + assert.strictEqual(expectations.length, 0); + }); + + test('checkoutCommit rejects a real SHA-shaped branch that points to another commit', async () => { + const repoPath = await fs.mkdtemp(join(tmpdir(), 'vscode-plugin-git-')); + temporaryDirectories.push(repoPath); + const runGit = async (...args: string[]): Promise => { + const { stdout } = await execFile('git', ['-C', repoPath, ...args], { encoding: 'utf8' }); + return stdout.trim(); + }; + + await runGit('init'); + await fs.writeFile(join(repoPath, 'payload.txt'), 'branch content'); + await runGit('add', 'payload.txt'); + await runGit('-c', 'user.name=VS Code Test', '-c', 'user.email=vscode-test@example.com', 'commit', '-m', 'branch commit'); + const initialCommit = await runGit('rev-parse', 'HEAD'); + const pinnedCommit = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + await runGit('branch', pinnedCommit); + + const service = new LocalGitService(new NullLogService()); + await assert.rejects(() => service.checkoutCommit('test-op', repoPath, pinnedCommit)); + + assert.strictEqual(await runGit('rev-parse', 'HEAD'), initialCommit); + }); }); diff --git a/src/vs/workbench/contrib/chat/browser/pluginGitCommandService.ts b/src/vs/workbench/contrib/chat/browser/pluginGitCommandService.ts index c653b758c97..1a92f380f48 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginGitCommandService.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginGitCommandService.ts @@ -161,21 +161,35 @@ export class BrowserPluginGitCommandService implements IPluginGitService { ? requestedRef.toLowerCase() : await resolveGitHubRefToSha(this._requestService, repo, requestedRef, authToken, cancel); - if (requestedSha === entry.sha.toLowerCase()) { + await this._materializeCommit(repoDir, entry, requestedSha, isFullSha ? entry.ref : requestedRef, authToken, cancel); + } + + async checkoutCommit(repoDir: URI, commit: string, token?: CancellationToken): Promise { + const expectedCommit = commit.trim().toLowerCase(); + if (!/^[0-9a-f]{40}$/.test(expectedCommit)) { + throw new Error(localize('pluginsInvalidPinnedCommit', "Pinned plugin commit '{0}' is not a full SHA-1 hash.", commit)); + } + + const entry = this._getCacheEntry(repoDir); + if (!entry) { + throw new Error(`Cannot checkout plugin: no cached metadata for ${repoDir.toString()}`); + } + if (entry.sha.toLowerCase() === expectedCommit) { return; } - try { - await fetchAndExtractGitHubRepo(this._requestService, this._fileService, this._logService, repo, requestedSha, repoDir, authToken, cancel); - this._setCacheEntry(repoDir, { - ...entry, - ref: isFullSha ? entry.ref : requestedRef, - sha: requestedSha, - fetchedAt: Date.now(), - }); - } catch (err) { - this._maybeLogTransientError(err, repo); - throw err; + const cancel = token ?? CancellationToken.None; + const authToken = await this._lookupGitHubToken(); + const repo: IGitHubRepoRef = { owner: entry.owner, repo: entry.repo }; + const resolvedCommit = (await resolveGitHubRefToSha(this._requestService, repo, expectedCommit, authToken, cancel)).toLowerCase(); + if (resolvedCommit !== expectedCommit) { + throw new Error(localize('pluginsPinnedCommitResolutionMismatch', "Pinned plugin commit '{0}' resolved to a different commit '{1}'.", commit, resolvedCommit)); + } + + await this._materializeCommit(repoDir, entry, resolvedCommit, entry.ref, authToken, cancel); + const checkedOutCommit = (await this.revParse(repoDir, 'HEAD')).toLowerCase(); + if (checkedOutCommit !== expectedCommit) { + throw new Error(localize('pluginsPinnedCommitCheckoutMismatch', "Pinned plugin commit '{0}' was not checked out. The repository is at commit '{1}'.", commit, checkedOutCommit)); } } @@ -209,6 +223,26 @@ export class BrowserPluginGitCommandService implements IPluginGitService { // -- helpers -------------------------------------------------------------- + private async _materializeCommit(repoDir: URI, entry: IBrowserPluginCacheEntry, commit: string, ref: string | undefined, authToken: string | undefined, token: CancellationToken): Promise { + if (commit === entry.sha.toLowerCase()) { + return; + } + + const repo: IGitHubRepoRef = { owner: entry.owner, repo: entry.repo }; + try { + await fetchAndExtractGitHubRepo(this._requestService, this._fileService, this._logService, repo, commit, repoDir, authToken, token); + this._setCacheEntry(repoDir, { + ...entry, + ref, + sha: commit, + fetchedAt: Date.now(), + }); + } catch (err) { + this._maybeLogTransientError(err, repo); + throw err; + } + } + private _parseOrThrow(cloneUrl: string): IGitHubRepoRef { const parsed = parseGitHubCloneUrl(cloneUrl); if (!parsed) { diff --git a/src/vs/workbench/contrib/chat/browser/pluginSources.ts b/src/vs/workbench/contrib/chat/browser/pluginSources.ts index 69a4a275843..9170df7ea68 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginSources.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginSources.ts @@ -204,7 +204,7 @@ abstract class AbstractGitPluginSource implements IPluginSource { try { if (git.sha) { - await this._pluginGit.checkout(repoDir, git.sha, true, token); + await this._pluginGit.checkoutCommit(repoDir, git.sha, token); return; } // git.ref is guaranteed non-nullish by the guard above diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginGitService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginGitService.ts index a57a429cf91..7423aebeacd 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginGitService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginGitService.ts @@ -37,6 +37,7 @@ export interface IPluginGitService { cloneRepository(cloneUrl: string, targetDir: URI, ref?: string, token?: CancellationToken): Promise; pull(repoDir: URI, token?: CancellationToken): Promise; checkout(repoDir: URI, treeish: string, detached?: boolean, token?: CancellationToken): Promise; + checkoutCommit(repoDir: URI, commit: string, token?: CancellationToken): Promise; revParse(repoDir: URI, ref: string): Promise; fetch(repoDir: URI, token?: CancellationToken): Promise; fetchRepository(repoDir: URI, token?: CancellationToken): Promise; diff --git a/src/vs/workbench/contrib/chat/electron-browser/pluginGitCommandService.ts b/src/vs/workbench/contrib/chat/electron-browser/pluginGitCommandService.ts index f7e4593e54d..17848f3ef31 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/pluginGitCommandService.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/pluginGitCommandService.ts @@ -44,6 +44,10 @@ export class NativePluginGitCommandService implements IPluginGitService { await this._withCancel(token, id => this._localGitService.checkout(id, repoDir.fsPath, treeish, detached)); } + async checkoutCommit(repoDir: URI, commit: string, token?: CancellationToken): Promise { + await this._withCancel(token, id => this._localGitService.checkoutCommit(id, repoDir.fsPath, commit)); + } + async revParse(repoDir: URI, ref: string): Promise { return this._localGitService.revParse(repoDir.fsPath, ref); } diff --git a/src/vs/workbench/contrib/chat/test/browser/pluginGitCommandService.test.ts b/src/vs/workbench/contrib/chat/test/browser/pluginGitCommandService.test.ts index 0be4f4e017b..a051ead7a9d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/pluginGitCommandService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/pluginGitCommandService.test.ts @@ -349,6 +349,30 @@ suite('BrowserPluginGitCommandService', () => { assert.strictEqual(await service.revParse(targetDir, 'HEAD'), '2222222222222222222222222222222222222222'); }); + test('checkoutCommit accepts a cached exact SHA without a request', async () => { + const commit = 'aabbccddeeff00112233445566778899aabbccdd'; + requestStub.queue('GET', /\/commits\/main$/, jsonResponse(200, { sha: commit })); + queueRepoFetch(requestStub, commit, { 'a.txt': 'a' }); + await service.cloneRepository('https://github.com/octocat/Hello-World.git', targetDir, 'main'); + + await service.checkoutCommit(targetDir, commit.toUpperCase()); + + assert.strictEqual(await service.revParse(targetDir, 'HEAD'), commit); + }); + + test('checkoutCommit rejects when a SHA resolves to another commit', async () => { + const cachedCommit = '1111111111111111111111111111111111111111'; + const pinnedCommit = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + requestStub.queue('GET', /\/commits\/main$/, jsonResponse(200, { sha: cachedCommit })); + queueRepoFetch(requestStub, cachedCommit, { 'a.txt': 'a' }); + await service.cloneRepository('https://github.com/octocat/Hello-World.git', targetDir, 'main'); + requestStub.queue('GET', new RegExp(`/commits/${pinnedCommit}$`), jsonResponse(200, { sha: cachedCommit })); + + await assert.rejects(() => service.checkoutCommit(targetDir, pinnedCommit), /resolved to a different commit/); + + assert.strictEqual(await service.revParse(targetDir, 'HEAD'), cachedCommit); + }); + test('throws when called for a target with no cached metadata', async () => { await assert.rejects(() => service.checkout(targetDir, 'abc'), /no cached metadata/); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts index 89299ba5c97..6d521857c08 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts @@ -30,6 +30,7 @@ suite('AgentPluginRepositoryService', () => { cloneRepository: async () => { }, pull: async () => false, checkout: async () => { }, + checkoutCommit: async () => { }, revParse: async () => '', fetch: async () => { }, fetchRepository: async () => { }, @@ -488,7 +489,7 @@ suite('AgentPluginRepositoryService', () => { const service = createService(async () => true, undefined, { revParse: async () => { calls.push('revParse'); return ''; }, fetch: async () => { calls.push('fetch'); }, - checkout: async () => { calls.push('checkout'); }, + checkoutCommit: async () => { calls.push('checkoutCommit'); }, pull: async () => { calls.push('pull'); return false; }, }); @@ -511,7 +512,7 @@ suite('AgentPluginRepositoryService', () => { marketplaceType: MarketplaceType.Copilot, }); - assert.deepStrictEqual(calls, ['revParse', 'fetch', 'checkout', 'revParse']); + assert.deepStrictEqual(calls, ['revParse', 'fetch', 'checkoutCommit', 'revParse']); }); // ========================================================================= diff --git a/src/vs/workbench/contrib/chat/test/electron-browser/pluginGitCommandService.test.ts b/src/vs/workbench/contrib/chat/test/electron-browser/pluginGitCommandService.test.ts index 8006839c8fc..759f98eb473 100644 --- a/src/vs/workbench/contrib/chat/test/electron-browser/pluginGitCommandService.test.ts +++ b/src/vs/workbench/contrib/chat/test/electron-browser/pluginGitCommandService.test.ts @@ -19,6 +19,7 @@ suite('NativePluginGitCommandService', () => { clone: async () => { }, pull: async () => false, checkout: async () => { }, + checkoutCommit: async () => { }, revParse: async () => '', fetch: async () => { }, revListCount: async () => 0, @@ -62,6 +63,20 @@ suite('NativePluginGitCommandService', () => { assert.deepStrictEqual(calls, ['checkout:abc123:true']); }); + test('checkoutCommit delegates to ILocalGitService', async () => { + const calls: string[] = []; + const service = new NativePluginGitCommandService(createLocalGitStub({ + checkoutCommit: async (_operationId, path, commit) => { + calls.push(`checkoutCommit:${path}:${commit}`); + }, + })); + + const repoDir = URI.file('/tmp/repo'); + await service.checkoutCommit(repoDir, 'aabbccddeeff00112233445566778899aabbccdd'); + + assert.deepStrictEqual(calls, [`checkoutCommit:${repoDir.fsPath}:aabbccddeeff00112233445566778899aabbccdd`]); + }); + test('revParse delegates to ILocalGitService', async () => { const service = new NativePluginGitCommandService(createLocalGitStub({ revParse: async () => 'abc123', From 499be98b61d7e99165dbbab40fdac4fbdd73d2bb Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:04:33 +0000 Subject: [PATCH 023/116] Preserve user picker state for orchestration when Agent Merge is enabled (#332609) * Initial plan * Avoid inheriting agent-merge overrides in session creation defaults Co-authored-by: benibenj <44439583+benibenj@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: benibenj <44439583+benibenj@users.noreply.github.com> --- .../platform/agentHost/common/agentMerge.ts | 33 +++++++ .../platform/agentHost/node/agentService.ts | 4 +- .../agentHost/test/common/agentMerge.test.ts | 44 +++++++++- .../agentHost/test/node/agentService.test.ts | 86 +++++++++++++++++++ 4 files changed, 164 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentMerge.ts b/src/vs/platform/agentHost/common/agentMerge.ts index deb50a323ff..d5ef31e49af 100644 --- a/src/vs/platform/agentHost/common/agentMerge.ts +++ b/src/vs/platform/agentHost/common/agentMerge.ts @@ -5,6 +5,7 @@ import { localize } from '../../../nls.js'; import { appendEscapedMarkdownInlineCode } from '../../../base/common/htmlContent.js'; +import { structuralEquals } from '../../../base/common/equals.js'; import { createSchema, schemaProperty } from './agentHostSchema.js'; import { GitHubActor, PullRequestCheck, PullRequestChecks, PullRequestSnapshot } from '../../github/common/githubPullRequestService.js'; import { SessionConfigKey } from './sessionConfigKeys.js'; @@ -291,6 +292,38 @@ export function readAgentMergeSessionState(values: Record | und }; } +/** + * Returns session config values with Agent Merge injected overrides removed, + * so callers can read the user's own picker selections while merge is active. + */ +export function getNonMergeSessionConfigValues(values: Readonly> | undefined): Readonly> { + if (!values) { + return {}; + } + const agentMerge = readAgentMergeSessionState(values as Record); + const injected = agentMerge?.injectedConfiguration; + if (!agentMerge?.enabled || !injected) { + return values; + } + const restored = { ...values }; + for (const [key, appliedValue] of Object.entries(injected.applied)) { + if (!structuralEquals(restored[key], appliedValue)) { + continue; + } + if (Object.hasOwn(injected.previous, key)) { + const previousValue = injected.previous[key]; + if (previousValue === undefined) { + delete restored[key]; + } else { + restored[key] = previousValue; + } + } else { + delete restored[key]; + } + } + return restored; +} + export function isAgentMergeFeedbackAuthor(actor: GitHubActor | undefined): boolean { if (!actor) { return false; diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index c128778ea95..2173b7a7f26 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -79,7 +79,7 @@ import { resolveLastNonLocalTurnId } from '../common/agentHostConversationContex import { AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; import { AgentMergeController, type IAgentMergeControllerOptions } from './agentMergeController.js'; -import { AgentMergeConfigKey, agentMergeRootConfigSchema, readAgentMergeSessionState } from '../common/agentMerge.js'; +import { AgentMergeConfigKey, agentMergeRootConfigSchema, getNonMergeSessionConfigValues, readAgentMergeSessionState } from '../common/agentMerge.js'; import { AgentSystemNotificationKind, toAgentSystemNotificationMeta } from '../common/meta/agentSystemNotificationMeta.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AgentHostAuthenticationService } from './agentHostAuthenticationService.js'; @@ -1151,7 +1151,7 @@ export class AgentService extends Disposable implements IAgentService { : session.draft ? session.draft.model : session.turns.at(-1)?.message.model; - const config = this._providers.get(session.provider)?.getInheritedChatConfig(session.config?.values ?? {}); + const config = this._providers.get(session.provider)?.getInheritedChatConfig(getNonMergeSessionConfigValues(session.config?.values)); return { provider: session.provider, ...(model !== undefined ? { model } : {}), diff --git a/src/vs/platform/agentHost/test/common/agentMerge.test.ts b/src/vs/platform/agentHost/test/common/agentMerge.test.ts index 3fbeec053b2..4e525609058 100644 --- a/src/vs/platform/agentHost/test/common/agentMerge.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMerge.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { AgentMergeConfiguration, evaluateAgentMerge, readAgentMergeSessionState } from '../../common/agentMerge.js'; +import { AgentMergeConfiguration, evaluateAgentMerge, getNonMergeSessionConfigValues, readAgentMergeSessionState } from '../../common/agentMerge.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { PullRequestSnapshot } from '../../../github/common/githubPullRequestService.js'; @@ -191,6 +191,48 @@ suite('Agent Merge gate', () => { lastPromptFingerprint: 'fingerprint', }); }); + + test('returns pre-merge picker values when merge-injected values are active', () => { + const values = { + [SessionConfigKey.AgentMerge]: { enabled: true }, + [SessionConfigKey.AgentMergeController]: { + injectedConfiguration: { + previous: { + autoApprove: 'default', + mode: 'interactive', + permissionMode: 'acceptEdits', + }, + applied: { + autoApprove: 'assisted', + mode: 'autopilot', + permissionMode: 'auto', + }, + }, + }, + autoApprove: 'assisted', + mode: 'autopilot', + permissionMode: 'auto', + permissions: { allow: ['shell'] }, + }; + assert.deepStrictEqual(getNonMergeSessionConfigValues(values), { + [SessionConfigKey.AgentMerge]: { enabled: true }, + [SessionConfigKey.AgentMergeController]: values[SessionConfigKey.AgentMergeController], + autoApprove: 'default', + mode: 'interactive', + permissionMode: 'acceptEdits', + permissions: { allow: ['shell'] }, + }); + }); + + test('leaves session config unchanged when merge is disabled', () => { + const values = { + [SessionConfigKey.AgentMerge]: { enabled: false }, + autoApprove: 'autoApprove', + mode: 'plan', + permissionMode: 'plan', + }; + assert.deepStrictEqual(getNonMergeSessionConfigValues(values), values); + }); }); function readySnapshot(overrides?: { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 26b0db1e8a2..ff9fa7ffe8a 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -10821,6 +10821,92 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('session creation tools inherit pre-merge picker values when agent merge is enabled', async () => { + class ServerToolAgent extends MockAgent { + readonly createSessionConfigs: (IAgentCreateSessionConfig | undefined)[] = []; + serverToolHost: IAgentServerToolHost | undefined; + + constructor(id: string) { + super(id); + Object.assign(this, { + getInheritedChatConfig: (config: Readonly> = {}): Record | undefined => { + const inherited: Record = {}; + for (const key of [SessionConfigKey.AutoApprove, SessionConfigKey.Mode, ClaudeSessionConfigKey.PermissionMode, CodexSessionConfigKey.PermissionsPreset]) { + if (config[key] !== undefined) { + inherited[key] = config[key]; + } + } + return inherited; + }, + }); + } + + setServerToolHost(host: IAgentServerToolHost): void { + this.serverToolHost = host; + } + + override readonly chats: IAgentChats = withChatOverrides(getChatSurface(this), base => ({ + createChat: async (chat, context, options) => { + const result = await base.createChat(chat, context, options); + if (result) { + this.createSessionConfigs.push({ session: resolveAgentChatContext(context, chat).configurationResource, model: options?.model, workingDirectories: options?.workingDirectories, config: options?.config }); + } + return result; + }, + })); + } + + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new ServerToolAgent('copilot')); + localService.registerProvider(agent); + const sourceSession = await localService.createSession({ provider: 'copilot' }); + const sourceChat = buildDefaultChatUri(sourceSession); + getStateManager(localService).setSessionConfig(sourceSession.toString(), { + schema: { type: 'object', properties: {} }, + values: { + [SessionConfigKey.AgentMerge]: { enabled: true }, + [SessionConfigKey.AgentMergeController]: { + injectedConfiguration: { + previous: { + [SessionConfigKey.AutoApprove]: 'default', + [SessionConfigKey.Mode]: 'interactive', + [ClaudeSessionConfigKey.PermissionMode]: 'acceptEdits', + [CodexSessionConfigKey.PermissionsPreset]: 'read-only', + }, + applied: { + [SessionConfigKey.AutoApprove]: 'assisted', + [SessionConfigKey.Mode]: 'autopilot', + [ClaudeSessionConfigKey.PermissionMode]: 'auto', + [CodexSessionConfigKey.PermissionsPreset]: 'danger-full-access', + }, + }, + }, + [SessionConfigKey.AutoApprove]: 'assisted', + [SessionConfigKey.Mode]: 'autopilot', + [ClaudeSessionConfigKey.PermissionMode]: 'auto', + [CodexSessionConfigKey.PermissionsPreset]: 'danger-full-access', + }, + }); + localService.dispatchAction(sourceChat, { + type: ActionType.ChatTurnStarted, + turnId: 'source-turn', + startedAt: new Date().toISOString(), + message: { text: 'create a child session', origin: { kind: MessageKind.User }, model: { id: 'source-model' } }, + }, 'test-client', 1); + + await agent.serverToolHost!.executeTool(sourceChat, SessionServerToolName.CreateSession, { + workspace: URI.file('/workspace').toString(), + prompt: 'new session', + }); + + assert.deepStrictEqual(agent.createSessionConfigs.at(-1)?.config, { + [SessionConfigKey.AutoApprove]: 'default', + [SessionConfigKey.Mode]: 'interactive', + [ClaudeSessionConfigKey.PermissionMode]: 'acceptEdits', + [CodexSessionConfigKey.PermissionsPreset]: 'read-only', + }); + }); + test('session creation tools preserve the provider default model on the active turn', async () => { class ServerToolAgent extends MockAgent { readonly createSessionConfigs: (IAgentCreateSessionConfig | undefined)[] = []; From 873ac47bc6f6a9e44e06b281a1436ae493d1d715 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Tue, 25 Aug 2026 16:21:33 -0700 Subject: [PATCH 024/116] Avoid decoration shutdown listeners for detached terminals (#332476) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../terminal/browser/xterm/decorationAddon.ts | 3 -- .../terminal/browser/xterm/xtermTerminal.ts | 5 +++ .../test/browser/xterm/xtermTerminal.test.ts | 35 +++++++++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/decorationAddon.ts b/src/vs/workbench/contrib/terminal/browser/xterm/decorationAddon.ts index 84bbf8c0cdd..0294c9fae45 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/decorationAddon.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/decorationAddon.ts @@ -24,7 +24,6 @@ import { IThemeService } from '../../../../../platform/theme/common/themeService import { terminalDecorationMark } from '../terminalIcons.js'; import { DecorationSelector, getTerminalCommandDecorationState, getTerminalDecorationHoverContent, updateLayout } from './decorationStyles.js'; import { TERMINAL_COMMAND_DECORATION_DEFAULT_BACKGROUND_COLOR, TERMINAL_COMMAND_DECORATION_ERROR_BACKGROUND_COLOR, TERMINAL_COMMAND_DECORATION_SUCCESS_BACKGROUND_COLOR } from '../../common/terminalColorRegistry.js'; -import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { IChatContextPickService } from '../../../chat/browser/attachments/chatContextPickService.js'; @@ -61,7 +60,6 @@ export class DecorationAddon extends Disposable implements ITerminalAddon, IDeco @IThemeService private readonly _themeService: IThemeService, @IOpenerService private readonly _openerService: IOpenerService, @IQuickInputService private readonly _quickInputService: IQuickInputService, - @ILifecycleService lifecycleService: ILifecycleService, @ICommandService private readonly _commandService: ICommandService, @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, @INotificationService private readonly _notificationService: INotificationService, @@ -86,7 +84,6 @@ export class DecorationAddon extends Disposable implements ITerminalAddon, IDeco this._updateDecorationVisibility(); this._register(this._capabilities.onDidAddCapability(c => this._createCapabilityDisposables(c.id))); this._register(this._capabilities.onDidRemoveCapability(c => this._removeCapabilityDisposables(c.id))); - this._register(lifecycleService.onWillShutdown(() => this._disposeAllDecorations())); } private _createCapabilityDisposables(c: TerminalCapability): void { diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index b86678dc8de..4fa25000012 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -49,6 +49,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { isNumber } from '../../../../../base/common/types.js'; import { clamp } from '../../../../../base/common/numbers.js'; import { LayoutSettings } from '../../../../services/layout/browser/layoutService.js'; +import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; const enum RenderConstants { SmoothScrollDuration = 125 @@ -224,6 +225,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach @IClipboardService private readonly _clipboardService: IClipboardService, @IContextKeyService contextKeyService: IContextKeyService, @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, + @ILifecycleService lifecycleService: ILifecycleService, @ILayoutService layoutService: ILayoutService ) { super(); @@ -328,6 +330,9 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach this._register(this._decorationAddon.onDidRequestRunCommand(e => this._onDidRequestRunCommand.fire(e))); this._register(this._decorationAddon.onDidRequestCopyAsHtml(e => this._onDidRequestCopyAsHtml.fire(e))); this.raw.loadAddon(this._decorationAddon); + if (!options.detached) { + this._register(lifecycleService.onWillShutdown(() => this._decorationAddon.clearDecorations())); + } this._shellIntegrationAddon = new ShellIntegrationAddon(options.shellIntegrationNonce ?? '', options.disableShellIntegrationReporting, this._onDidExecuteText, this._telemetryService, this._logService); this.raw.loadAddon(this._shellIntegrationAddon); this._xtermAddonLoader.importAddon('clipboard').then(ClipboardAddon => { diff --git a/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts b/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts index 5294263f9a6..68b48ce1a31 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts @@ -21,10 +21,12 @@ import { IThemeService } from '../../../../../../platform/theme/common/themeServ import { TestColorTheme, TestThemeService } from '../../../../../../platform/theme/test/common/testThemeService.js'; import { PANEL_BACKGROUND, SIDE_BAR_BACKGROUND } from '../../../../../common/theme.js'; import { IViewDescriptor, IViewDescriptorService, ViewContainerLocation } from '../../../../../common/views.js'; +import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js'; import { XtermTerminal } from '../../../browser/xterm/xtermTerminal.js'; import { ITerminalConfiguration, TERMINAL_VIEW_ID } from '../../../common/terminal.js'; import { registerColors, TERMINAL_BACKGROUND_COLOR, TERMINAL_CURSOR_BACKGROUND_COLOR, TERMINAL_CURSOR_FOREGROUND_COLOR, TERMINAL_FOREGROUND_COLOR, TERMINAL_INACTIVE_SELECTION_BACKGROUND_COLOR, TERMINAL_SELECTION_BACKGROUND_COLOR, TERMINAL_SELECTION_FOREGROUND_COLOR } from '../../../common/terminalColorRegistry.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { TestLifecycleService } from '../../../../../test/common/workbenchTestServices.js'; import { TestWebglAddon, TestXtermAddonImporter } from './xtermTestUtils.js'; registerColors(); @@ -60,6 +62,10 @@ const defaultTerminalConfig: Partial = { unicodeVersion: '6' }; +function listenerCount(emitter: Emitter): number { + return (emitter as unknown as { _size: number })._size ?? 0; +} + suite('XtermTerminal', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -68,6 +74,8 @@ suite('XtermTerminal', () => { let themeService: TestThemeService; let xterm: XtermTerminal; let XTermBaseCtor: typeof Terminal; + let onWillShutdown: Emitter; + let lifecycleListenerCountBeforeXterm: number; function write(data: string): Promise { return new Promise((resolve) => { @@ -91,6 +99,9 @@ suite('XtermTerminal', () => { configurationService: () => configurationService }, store); themeService = instantiationService.get(IThemeService) as TestThemeService; + const lifecycleService = instantiationService.get(ILifecycleService) as TestLifecycleService; + onWillShutdown = (lifecycleService as unknown as { _onWillShutdown: Emitter })._onWillShutdown; + lifecycleListenerCountBeforeXterm = listenerCount(onWillShutdown); XTermBaseCtor = (await importAMDNodeModule('@xterm/xterm', 'lib/xterm.js')).Terminal; @@ -114,6 +125,30 @@ suite('XtermTerminal', () => { strictEqual(xterm.raw.rows, 30); }); + test('detached terminals do not register decoration shutdown listeners', () => { + const listenerCountAfterRegularXterm = listenerCount(onWillShutdown); + for (let index = 0; index < 50; index++) { + const capabilityStore = store.add(new TerminalCapabilityStore()); + store.add(instantiationService.createInstance(XtermTerminal, undefined, XTermBaseCtor, { + cols: 80, + rows: 30, + xtermColorProvider: { getBackgroundColor: () => undefined }, + capabilities: capabilityStore, + disableShellIntegrationReporting: true, + xtermAddonImporter: new TestXtermAddonImporter(), + detached: true, + }, undefined)); + } + + deepStrictEqual({ + regularXtermListeners: listenerCountAfterRegularXterm - lifecycleListenerCountBeforeXterm, + detachedXtermListeners: listenerCount(onWillShutdown) - listenerCountAfterRegularXterm, + }, { + regularXtermListeners: 1, + detachedXtermListeners: 0, + }); + }); + test('disables custom glyphs when moved into an auxiliary window', async () => { await configurationService.setUserConfiguration('terminal.integrated', { ...defaultTerminalConfig, From 59bb1a833fec0338b3e2d33917732dc1ba25e117 Mon Sep 17 00:00:00 2001 From: Bryan Chen <41454397+bryanchen-d@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:21:54 -0700 Subject: [PATCH 025/116] Stop observing chat layout while the pet is disabled (#332346) * fix(chat): stop observing layout while pet is disabled Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aacd276e-cf84-48bd-a2ab-6f6a4d4c3431 * test(chat): update disabled pet fixture host Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aacd276e-cf84-48bd-a2ab-6f6a4d4c3431 * test(chat): isolate pet resize observer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aacd276e-cf84-48bd-a2ab-6f6a4d4c3431 --------- Copilot-Session: aacd276e-cf84-48bd-a2ab-6f6a4d4c3431 --- .../chat/browser/widget/chatPetWidget.ts | 12 ++-- .../browser/widget/chatPetWidgetService.ts | 2 +- .../test/browser/widget/chatPetWidget.test.ts | 45 ++++++++++++ .../chat/chatWidget.fixture.ts | 70 +++++++++++++++++++ .../tests/chatPetResizeObserver.spec.ts | 26 +++++++ 5 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 test/componentFixtures/playwright/tests/chatPetResizeObserver.spec.ts diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts index cd31d4d3a3c..c072ab7402a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts @@ -1159,6 +1159,7 @@ export class ChatPetWidget extends Disposable { constructor( host: IChatPetWidgetHost, + resizeObserverCtor: typeof ResizeObserver | undefined, @IChatPetService private readonly chatPetService: IChatPetService, @IAccessibilityService private readonly accessibilityService: IAccessibilityService, @IContextMenuService private readonly contextMenuService: IContextMenuService, @@ -1244,7 +1245,7 @@ export class ChatPetWidget extends Disposable { speechBubbleImage.alt = ''; speechBubbleImage.setAttribute('aria-hidden', 'true'); this._speechBubble = { container: speechBubbleContainer, image: speechBubbleImage, canvas: speechBubbleCanvas }; - this._resizeObserver = this._register(new dom.DisposableResizeObserver('ChatPetWidget.dragBounds', () => this._handleHostLayoutChange(), dom.getWindow(this._button.element))); + this._resizeObserver = this._register(new dom.DisposableResizeObserver('ChatPetWidget.dragBounds', () => this._handleHostLayoutChange(), dom.getWindow(this._button.element), { resizeObserverCtor })); this._observeHost(host); if (this._getHorizontalBounds() !== undefined) { this._restoreHorizontalPosition(); @@ -1451,6 +1452,7 @@ export class ChatPetWidget extends Disposable { const wasInitialized = this._enablementInitialized; this._enablementInitialized = true; this._enabled = enabled; + this._observeHost(this._host.read(undefined)); if (enabled) { if (isDead) { this._showRespawnSequence(); @@ -1558,9 +1560,11 @@ export class ChatPetWidget extends Disposable { private _observeHost(host: IChatPetWidgetHost): void { const store = new DisposableStore(); - store.add(this._resizeObserver.observe(host.dragBounds)); - store.add(this._resizeObserver.observe(host.movementBounds)); - store.add(this._resizeObserver.observe(host.parent)); + if (this._enabled) { + store.add(this._resizeObserver.observe(host.dragBounds)); + store.add(this._resizeObserver.observe(host.movementBounds)); + store.add(this._resizeObserver.observe(host.parent)); + } store.add(host.onDidChangePlatform(() => this._updatePlatformPosition())); this._hostLayoutDisposables.value = store; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidgetService.ts b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidgetService.ts index b869eb05703..a2acc4a5712 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidgetService.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidgetService.ts @@ -218,7 +218,7 @@ export class ChatPetWidgetService extends Disposable implements IChatPetWidgetSe ) { super(); this.coordinator = this._register(new ChatPetWidgetCoordinator( - host => instantiationService.createInstance(ChatPetWidget, host), + host => instantiationService.createInstance(ChatPetWidget, host, undefined), chatWidgetService, Event.map(dom.onWillUnregisterWindow, window => dom.getWindowId(window)), )); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts index 73ecdd87d81..55d2e1d395f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts @@ -105,6 +105,7 @@ suite('ChatPetWidget', () => { const service = disposables.add(new ChatPetService(disposables.add(new TestStorageService()), new TestTelemetryService(), new NullLogService())); disposables.add(new ChatPetWidget( createPetHost(parent, dragBounds, movementBounds), + undefined, service, new TestAccessibilityService(), new class extends mock() { }(), @@ -130,6 +131,46 @@ suite('ChatPetWidget', () => { }); }); + test('observes layout bounds only while visible and enabled', () => { + const observedTargets = new Set(); + class TestResizeObserver implements ResizeObserver { + observe(target: Element): void { observedTargets.add(target); } + unobserve(target: Element): void { observedTargets.delete(target); } + disconnect(): void { observedTargets.clear(); } + takeRecords(): ResizeObserverEntry[] { return []; } + } + const parent = mainWindow.document.createElement('div'); + const dragBounds = mainWindow.document.createElement('div'); + const movementBounds = mainWindow.document.createElement('div'); + mainWindow.document.body.append(parent, dragBounds, movementBounds); + disposables.add(toDisposable(() => { + parent.remove(); + dragBounds.remove(); + movementBounds.remove(); + })); + const service = disposables.add(new ChatPetService(disposables.add(new TestStorageService()), new TestTelemetryService(), new NullLogService())); + disposables.add(new ChatPetWidget( + createPetHost(parent, dragBounds, movementBounds), + TestResizeObserver as unknown as typeof ResizeObserver, + service, + new TestAccessibilityService(), + new class extends mock() { }(), + new class extends mock() { }(), + new NullLogService(), + new class extends mock() { + override readonly hasFocus = true; + override readonly onDidChangeFocus = Event.None; + override readonly onDidChangeActiveWindow = Event.None; + }(), + )); + + assert.strictEqual(observedTargets.size, 0); + service.toggle(); + assert.deepStrictEqual(observedTargets, new Set([dragBounds, movementBounds, parent])); + service.toggle(); + assert.strictEqual(observedTargets.size, 0); + }); + test('stacks the run cycle behind the input', () => { const parent = mainWindow.document.createElement('div'); const input = mainWindow.document.createElement('div'); @@ -144,6 +185,7 @@ suite('ChatPetWidget', () => { service.toggle(); disposables.add(new ChatPetWidget( createPetHost(parent, input, movementBounds), + undefined, service, new class extends TestAccessibilityService { override isMotionReduced(): boolean { return false; } @@ -223,6 +265,7 @@ suite('ChatPetWidget', () => { const service = disposables.add(new ChatPetService(disposables.add(new TestStorageService()), new TestTelemetryService(), new NullLogService())); const widget = disposables.add(new ChatPetWidget( createPetHost(firstParent, firstBounds, movementBounds), + undefined, service, new TestAccessibilityService(), new class extends mock() { }(), @@ -449,6 +492,7 @@ suite('ChatPetWidget', () => { const service = disposables.add(new ChatPetService(disposables.add(new TestStorageService()), new TestTelemetryService(), new NullLogService())); disposables.add(new ChatPetWidget( createPetHost(parent, dragBounds, movementBounds), + undefined, service, new TestAccessibilityService(), new class extends mock() { }(), @@ -646,6 +690,7 @@ suite('ChatPetWidget', () => { const service = disposables.add(new ChatPetService(storageService, new TestTelemetryService(), new NullLogService())); const widget = disposables.add(new ChatPetWidget( createPetHost(parent, dragBounds, movementBounds), + undefined, service, new TestAccessibilityService(), new class extends mock() { }(), diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts index 66b474587de..e42e8bb512c 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts @@ -38,6 +38,7 @@ import { MockChatService } from '../../../../contrib/chat/test/common/chatServic import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; import { FixtureMenuService, registerChatFixtureServices } from './chatFixtureUtils.js'; import { ChatTurnStatusPillsSetting, isChatTurnStatusPillsEnabled } from '../../../../contrib/chat/browser/widget/chatTurnPills.js'; +import { ChatPetWidget } from '../../../../contrib/chat/browser/widget/chatPetWidget.js'; import '../../../../contrib/chat/browser/widget/media/chat.css'; @@ -829,6 +830,70 @@ async function renderResizeObserverLoopHarness(context: ComponentFixtureContext, })); } +async function renderDisabledPetResizeObserverProbe(context: ComponentFixtureContext): Promise { + const targetWindow = dom.getWindow(context.container); + const instantiationService = createEditorServices(context.disposableStore, { + colorTheme: context.theme, + additionalServices: registerChatFixtureServices, + }); + context.container.style.width = '720px'; + context.container.style.height = '600px'; + const movementBounds = dom.append(context.container, dom.$('.disabled-pet-movement-bounds')); + const petHost = dom.append(movementBounds, dom.$('.disabled-pet-host')); + const dragBounds = dom.append(petHost, dom.$('.disabled-pet-drag-bounds')); + const trigger = dom.append(dragBounds, dom.$('.disabled-pet-resize-observer-trigger')); + movementBounds.style.width = '100%'; + movementBounds.style.height = '200px'; + petHost.style.width = '100%'; + petHost.style.height = '100px'; + dragBounds.style.width = '100%'; + dragBounds.style.height = '100%'; + trigger.style.width = '10px'; + trigger.style.height = '10px'; + context.disposableStore.add(instantiationService.createInstance( + ChatPetWidget, + { + parent: petHost, + dragBounds, + movementBounds, + model: constObservable(undefined), + hasInput: constObservable(false), + inputChanged: Event.None, + getPlatformTop: () => undefined, + onDidChangePlatform: Event.None, + }, + undefined, + )); + + const status = dom.append(context.container, dom.$('.disabled-pet-resize-observer-status')); + status.role = 'status'; + status.textContent = 'Running disabled pet observer probe'; + status.dataset['warningCount'] = '0'; + context.disposableStore.add(dom.addDisposableListener(targetWindow, dom.EventType.ERROR, event => { + if (event instanceof ErrorEvent && event.message.includes('ResizeObserver loop')) { + status.dataset['warningCount'] = String(Number(status.dataset['warningCount']) + 1); + status.dataset['observerContext'] = dom.getRecentDisposableResizeObserverContextForLoopError(event.message, targetWindow) ?? event.message; + } + })); + + let triggerCallbacks = 0; + const triggerObserver = context.disposableStore.add(new dom.DisposableResizeObserver('DisabledPetFixture.deepTrigger', () => { + triggerCallbacks++; + if (triggerCallbacks === 2) { + dragBounds.style.height = `${dragBounds.getBoundingClientRect().height + 1}px`; + } + }, targetWindow)); + context.disposableStore.add(triggerObserver.observe(trigger)); + + const nextFrame = () => new Promise(resolve => targetWindow.requestAnimationFrame(() => resolve())); + await nextFrame(); + await nextFrame(); + trigger.style.width = '11px'; + await nextFrame(); + await nextFrame(); + status.textContent = 'Completed disabled pet observer probe'; +} + export default defineThemedFixtureGroup({ path: 'chat/widget/' }, { SimpleQA: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: SIMPLE_QA }) }), ScrollToBottomAction: defineComponentFixture({ render: renderScrollToBottomAction }), @@ -854,6 +919,11 @@ export default defineThemedFixtureGroup({ path: 'chat/widget/' }, { virtualTime: { enabled: false }, render: context => renderResizeObserverLoopHarness(context, 'none'), }), + DisabledPetResizeObserverProbe: defineComponentFixture({ + labels: { kind: 'animated' }, + virtualTime: { enabled: false }, + render: renderDisabledPetResizeObserverProbe, + }), CodeBlockInList: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: CODE_BLOCK_IN_LIST }) }), bugs: defineThemedFixtureGroup({ 'issue-309796-missing-backslash': defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: ISSUE_309796_MISSING_BACKSLASH }) }), diff --git a/test/componentFixtures/playwright/tests/chatPetResizeObserver.spec.ts b/test/componentFixtures/playwright/tests/chatPetResizeObserver.spec.ts new file mode 100644 index 00000000000..4756e88f291 --- /dev/null +++ b/test/componentFixtures/playwright/tests/chatPetResizeObserver.spec.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { expect, test } from '@playwright/test'; +import { openFixture } from './utils.js'; + +test('does not observe chat layout while the pet is disabled', async ({ page }) => { + const resizeObserverErrors: string[] = []; + page.on('pageerror', error => { + if (error.message.includes('ResizeObserver loop')) { + resizeObserverErrors.push(error.message); + } + }); + + await openFixture(page, 'chat/widget/chatWidget/DisabledPetResizeObserverProbe/Dark', '.disabled-pet-resize-observer-status'); + await expect(page.getByRole('status')).toContainText('Completed'); + const status = page.locator('.disabled-pet-resize-observer-status'); + const warningCount = Number(await status.getAttribute('data-warning-count')); + const observerContext = await status.getAttribute('data-observer-context'); + console.log(`[disabled-pet-resize-observer] warnings: ${warningCount}; page errors: ${resizeObserverErrors.length}; observer context: ${observerContext}`); + + expect(warningCount).toBe(0); + expect(resizeObserverErrors).toEqual([]); +}); From 692f114bc721f4ffa282911687dcce39080c9a8a Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega <48293249+osortega@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:30:24 -0700 Subject: [PATCH 026/116] Do not cache a session-state subscription that failed to subscribe (#332612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Do not cache a session-state subscription that failed to subscribe Pinning a visible session is deliberately idempotent so that "re-running per tick also recovers a subscription that could not be created earlier". That recovery never ran: a subscribe that failed was stored in `_sessionStateSubscriptions` like any other, so every later tick saw a live subscription and returned early. A failed subscribe is easy to miss because it settles via `onDidError` and never fires `onDidChange` — the only event this consumer listened to — so the session simply stopped receiving state, silently and permanently. Its changesets never reached the adapter, which left the Changes view empty for the rest of the session. This is reachable whenever a session is addressed before the host has created it: a cloud sandbox subscribes with the id Mission Control minted, roughly 700ms before `createSession`, and the host answers NotFound. The same applies to the momentarily-disconnected remote the existing comment describes. The subscription is now dropped when it errors, in both directions: bail without caching if it already carries an error, and delete the entry if one arrives later. The next tick then re-subscribes, by which point the session exists. 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> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../browser/baseAgentHostSessionsProvider.ts | 14 +++++ .../remoteAgentHostSessionsProvider.test.ts | 63 ++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 82e4944f178..0d965bc799f 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -4890,11 +4890,25 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } const sessionUri = cached.backendUri; const ref = connection.getSubscription(StateComponents.Session, sessionUri, 'BaseAgentHostSessionsProvider.summary'); + // Do not cache failures, so a later pin can retry sessions addressed before host creation. + if (ref.object.value instanceof Error) { + ref.dispose(); + return; + } const store = new DisposableStore(); store.add(ref); store.add(ref.object.onDidChange(state => { this._applySessionStateUpdate(sessionId, state); })); + // A subscribe that fails after this point settles via `onDidError`, never `onDidChange`. + const onDidError = ref.object.onDidError; + if (onDidError) { + store.add(onDidError(() => { + if (this._sessionStateSubscriptions.get(sessionId) === store) { + this._sessionStateSubscriptions.deleteAndDispose(sessionId); + } + })); + } this._sessionStateSubscriptions.set(sessionId, store); const value = ref.object.value; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 35e75880ea0..ed2e55c96ad 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -124,9 +124,15 @@ class MockAgentConnection extends mock() { // ---- Session-state subscriptions --------------------------------------- private readonly _sessionStateEmitters = new Map>(); + private readonly _sessionStateErrorEmitters = new Map>(); private readonly _sessionStateValues = new Map(); public sessionSubscribeCounts = new Map(); public sessionUnsubscribeCounts = new Map(); + /** + * Channel URIs whose next subscribe fails the way a session the host has not created yet + * does: the reference resolves, then settles into an error state via `onDidError`. + */ + public readonly failNextSessionSubscribe = new Set(); override getSubscription(_kind: StateComponents, resource: URI): IReference> { const key = resource.toString(); @@ -136,14 +142,29 @@ class MockAgentConnection extends mock() { emitter = new Emitter(); this._sessionStateEmitters.set(key, emitter); } + let errorEmitter = this._sessionStateErrorEmitters.get(key); + if (!errorEmitter) { + errorEmitter = new Emitter(); + this._sessionStateErrorEmitters.set(key, errorEmitter); + } + const failing = this.failNextSessionSubscribe.delete(key); const self = this; + let error: Error | undefined; const sub: IAgentSubscription = { - get value() { return self._sessionStateValues.get(key) as unknown as T | undefined; }, + get value() { return (error ?? self._sessionStateValues.get(key)) as unknown as T | Error | undefined; }, get verifiedValue() { return self._sessionStateValues.get(key) as unknown as T | undefined; }, onDidChange: emitter.event as unknown as Event, + onDidError: errorEmitter.event, onWillApplyAction: Event.None, onDidApplyAction: Event.None, }; + if (failing) { + // Defer the error so the consumer can attach listeners after the reference resolves. + queueMicrotask(() => { + error = new Error(`not found: ${key}`); + errorEmitter.fire(error); + }); + } return { object: sub, dispose: () => { @@ -179,6 +200,10 @@ class MockAgentConnection extends mock() { emitter.dispose(); } this._sessionStateEmitters.clear(); + for (const emitter of this._sessionStateErrorEmitters.values()) { + emitter.dispose(); + } + this._sessionStateErrorEmitters.clear(); } } @@ -1314,6 +1339,42 @@ suite('RemoteAgentHostSessionsProvider', () => { }); })); + test('re-subscribes to session state after a subscribe that failed because the host had no such session', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + // A failed pre-creation subscribe must remain retryable so later session state, including changesets, can arrive. + connection.addSession(createSession('late-1', { summary: 'Created after we asked' })); + const provider = createProvider(disposables, connection, { isWebPlatform: false, omitHostFromWorkspaceLabel: true }); + const backendUri = AgentSession.uri('copilotcli', 'late-1').toString(); + provider.getSessions(); + await timeout(0); + connection.failNextSessionSubscribe.add(backendUri); + + const session = provider.getSessions()[0]; + provider.getSessionByResource(session.resource); + await timeout(0); + const afterFailedSubscribe = connection.sessionSubscribeCounts.get(backendUri); + + // The host has created the session by the time anything asks again. + connection.setSessionState('late-1', 'copilotcli', { + provider: 'copilotcli', title: 'Created after we asked', status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + chats: [], + changesets: [{ label: 'Branch Changes', uriTemplate: 'changeset/branch', changeKind: 'branch' }], + } as unknown as SessionState); + provider.getSessionByResource(session.resource); + await timeout(0); + + assert.deepStrictEqual({ + afterFailedSubscribe, + afterRetry: connection.sessionSubscribeCounts.get(backendUri), + changesets: provider.getSessions()[0].changesets.get()?.map(c => c.id), + }, { + afterFailedSubscribe: 1, + afterRetry: 2, + changesets: ['branch'], + }); + })); + test('seedSessions never overwrites a project the host already reported', () => runWithFakedTimers({ useFakeTimers: true }, async () => { connection.addSession(createSession('authoritative-1', { summary: 'Authoritative', From 71f7505782a0272dd431371e204f4b2440c92521 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 25 Aug 2026 16:56:53 -0700 Subject: [PATCH 027/116] chat: resolve MCP App resources through agent host (#332653) * chat: resolve MCP App resources through agent host Routes resource links from agent-host MCP Apps through the connection-specific URI mapper. - Adds the connection authority to agent-host MCP App render data. - Converts App resource URIs with toAgentHostUri before the workbench uses them. - Updates adapter tests for the new render data. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: refresh MCP App model when authority changes Includes the connection authority when comparing agent-host MCP App render data. This recreates the model when resource routing moves to another agent-host connection. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/stateToProgressAdapter.ts | 17 +++++++++-------- .../toolInvocationParts/chatMcpAppModel.ts | 11 +++++------ .../chatToolInvocationPart.ts | 2 +- .../chat/common/chatService/chatService.ts | 2 ++ .../stateToProgressAdapter.test.ts | 3 +++ 5 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 729aea89d1f..c31f203d4be 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -401,7 +401,7 @@ function getSubagentChatResource(tc: ToolCallState, subagentContent: ToolResultS * scoping — two sessions exposing the same upstream MCP server therefore * get distinct webview origins (assuming distinct customization ids). */ -function getMcpAppData(tc: ToolCallState, _sessionResource: URI): ChatMcpAppData | undefined { +function getMcpAppData(tc: ToolCallState, connectionAuthority: string): ChatMcpAppData | undefined { if (tc.contributor?.kind !== ToolCallContributorKind.MCP) { return undefined; } @@ -420,6 +420,7 @@ function getMcpAppData(tc: ToolCallState, _sessionResource: URI): ChatMcpAppData return { kind: 'agentHost', resourceUri, + connectionAuthority, serverId: tc.contributor.customizationId, channel: channelValue, }; @@ -434,8 +435,8 @@ function getToolRawInput(tc: ToolCallState): unknown { } } -function buildMcpAppToolInputData(tc: ToolCallState, sessionResource: URI, existingRawInput?: unknown): IChatToolInputInvocationData | undefined { - const mcpAppData = getMcpAppData(tc, sessionResource); +function buildMcpAppToolInputData(tc: ToolCallState, connectionAuthority: string, existingRawInput?: unknown): IChatToolInputInvocationData | undefined { + const mcpAppData = getMcpAppData(tc, connectionAuthority); if (!mcpAppData) { return undefined; } @@ -451,7 +452,7 @@ function isSameMcpAppData(a: ChatMcpAppData | undefined, b: ChatMcpAppData | und return false; } if (a?.kind === 'agentHost' && b?.kind === 'agentHost') { - return a.serverId === b.serverId && a.channel === b.channel; + return a.serverId === b.serverId && a.channel === b.channel && a.connectionAuthority === b.connectionAuthority; } if (a?.kind === 'local' && b?.kind === 'local') { return a.serverDefinitionId === b.serverDefinitionId && a.collectionId === b.collectionId; @@ -1797,7 +1798,7 @@ export function completedToolCallToSerialized(tc: ICompletedToolCall, subAgentIn } else { toolSpecificData = buildSessionCreatedToolData(tc) ?? buildGeneratedImageToolData(tc) ?? buildAutomationConfiguredToolData(tc); if (!toolSpecificData) { - toolSpecificData = buildMcpAppToolInputData(tc, sessionResource); + toolSpecificData = buildMcpAppToolInputData(tc, connectionAuthority); } } @@ -2357,7 +2358,7 @@ export function toolCallStateToInvocation(tc: ToolCallState, subAgentInvocationI } else if (getToolKind(tc) === 'search') { invocation.toolSpecificData = { kind: 'search' }; } else if (tc.status !== ToolCallStatus.Streaming) { - invocation.toolSpecificData = buildMcpAppToolInputData(tc, sessionResource); + invocation.toolSpecificData = buildMcpAppToolInputData(tc, connectionAuthority); } return invocation; @@ -2541,7 +2542,7 @@ export function updateRunningToolSpecificData(existing: ChatToolInvocation, tc: // for non-MCP tools (search, terminal, …), so those fall through to the // handling below. const existingInput = existing.toolSpecificData?.kind === 'input' ? existing.toolSpecificData : undefined; - const nextInput = buildMcpAppToolInputData(tc, sessionResource, existingInput?.rawInput); + const nextInput = buildMcpAppToolInputData(tc, connectionAuthority, existingInput?.rawInput); if (nextInput) { if (!existingInput || !isSameMcpAppData(existingInput.mcpAppData, nextInput.mcpAppData)) { existing.toolSpecificData = nextInput; @@ -2683,7 +2684,7 @@ export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: ToolC if (isCompleted) { const mcpAppInput = buildMcpAppToolInputData( tc, - backendSession, + connectionAuthority, invocation.toolSpecificData?.kind === 'input' ? invocation.toolSpecificData.rawInput : undefined, ); if (mcpAppInput) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppModel.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppModel.ts index cf452c3ef77..4ceaf476b55 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppModel.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppModel.ts @@ -20,7 +20,7 @@ import { hasKey, isDefined } from '../../../../../../../base/common/types.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../../base/common/uuid.js'; import { localize } from '../../../../../../../nls.js'; -import { IChatResponseResourceFileSystemProvider } from '../../../../common/widget/chatResponseResourceFileSystemProvider.js'; +import { toAgentHostUri } from '../../../../../../../platform/agentHost/common/agentHostUri.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../../platform/log/common/log.js'; import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; @@ -34,6 +34,7 @@ import { McpApps } from '../../../../../mcp/common/modelContextProtocolApps.js'; import { IWebviewElement, IWebviewService, WebviewContentPurpose, WebviewOriginStore } from '../../../../../webview/browser/webview.js'; import { IChatRequestVariableEntry } from '../../../../common/attachments/chatVariableEntries.js'; import { IChatToolInvocation, IChatToolInvocationSerialized } from '../../../../common/chatService/chatService.js'; +import { IChatResponseResourceFileSystemProvider } from '../../../../common/widget/chatResponseResourceFileSystemProvider.js'; import { isToolResultInputOutputDetails, IToolResult } from '../../../../common/tools/languageModelToolsService.js'; import { IChatWidgetService } from '../../../chat.js'; import { IChatCollapsibleIODataPart } from '../chatToolInputOutputContentPart.js'; @@ -626,14 +627,12 @@ export class ChatMcpAppModel extends Disposable { * Resolves a server-relative resource URI into a workbench URI. * - Local servers: wrap in {@link McpResourceURI.fromServer} so it * resolves through the MCP filesystem provider. - * - Agent-host servers: pass through as a plain {@link URI}. There's - * no host-side resolver for AHP-backed servers in v1, so these - * URIs may not be openable, but they preserve the original - * resource reference for the user. + * - Agent-host servers: wrap with the originating connection authority + * so the URI resolves against the server that supplied it. */ private _resolveServerResourceUri(serverUri: string): URI { if (this.renderData.kind === 'agentHost') { - return URI.parse(serverUri); + return toAgentHostUri(URI.parse(serverUri), this.renderData.connectionAuthority); } return McpResourceURI.fromServer({ id: this.renderData.serverDefinitionId, label: '' }, serverUri); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts index b050ab417c0..27cea113726 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts @@ -57,7 +57,7 @@ function mcpAppRenderDataEquals(a: IMcpAppRenderData | undefined, b: IMcpAppRend return false; } if (a.kind === 'agentHost' && b.kind === 'agentHost') { - return a.serverId === b.serverId && a.channel === b.channel; + return a.serverId === b.serverId && a.channel === b.channel && a.connectionAuthority === b.connectionAuthority; } if (a.kind === 'local' && b.kind === 'local') { return a.serverDefinitionId === b.serverDefinitionId && a.collectionId === b.collectionId; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index d7ef160115f..6a83c95add8 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -766,6 +766,8 @@ export type ChatMcpAppData = kind: 'agentHost'; /** URI of the UI resource for rendering (e.g., "ui://weather-server/dashboard") */ resourceUri: string; + /** Sanitized connection identifier used to resolve App-provided resource URIs. */ + connectionAuthority: string; /** AHP `mcp://` channel URI for the originating server. */ channel: string; /** diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 5bcd41ec7b0..ccbf399ea51 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -655,6 +655,7 @@ suite('stateToProgressAdapter', () => { mcpAppData: { kind: 'agentHost', resourceUri: 'ui://github-mcp-server/pr-write', + connectionAuthority: 'local', serverId: 'github-customization', channel: 'mcp://copilot/session/GitHub', }, @@ -1536,6 +1537,7 @@ suite('stateToProgressAdapter', () => { mcpAppData: { kind: 'agentHost', resourceUri: 'ui://docs/app', + connectionAuthority: 'local', serverId: 'docs-customization', channel: 'mcp://copilot/test-session-1/docs', }, @@ -3174,6 +3176,7 @@ suite('stateToProgressAdapter', () => { mcpAppData: { kind: 'agentHost', resourceUri: 'ui://docs/app', + connectionAuthority: 'local', serverId: 'docs-customization', channel: 'mcp://copilot/test-session-1/docs', }, From 0a289c3721a428d911d29fb05b7ea1f72bc69b25 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 25 Aug 2026 17:04:15 -0700 Subject: [PATCH 028/116] cli: add frame-ancestors header to serve-web responses (#332654) Adds Content-Security-Policy frame-ancestors 'self' and X-Frame-Options SAMEORIGIN on all serve-web HTTP responses. Same-origin iframes continue to work. - Appends a CSP frame-ancestors 'self' directive on each response - Sets X-Frame-Options SAMEORIGIN on each response (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/src/commands/serve_web.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/cli/src/commands/serve_web.rs b/cli/src/commands/serve_web.rs index 8f2c147fa49..4c3b40cd31a 100644 --- a/cli/src/commands/serve_web.rs +++ b/cli/src/commands/serve_web.rs @@ -208,6 +208,7 @@ async fn handle( }; append_secret_headers(&ctx.cm.base_path, &mut res, &client_key_half); + append_frame_ancestors(&mut res); Ok(res) } @@ -276,6 +277,20 @@ fn append_secret_headers( ); } +/// Prevents other origins from embedding serve-web pages. Same-origin iframes +/// used by the workbench itself are still allowed. +fn append_frame_ancestors(res: &mut Response) { + let headers = res.headers_mut(); + headers.append( + ::http::header::CONTENT_SECURITY_POLICY, + "frame-ancestors 'self'".parse().unwrap(), + ); + headers.insert( + ::http::header::HeaderName::from_static("x-frame-options"), + "SAMEORIGIN".parse().unwrap(), + ); +} + /// Gets the release info from the VS Code path prefix, which is in the /// format `/-/...` fn get_release_from_path(path: &str, platform: Platform) -> Option<(Release, String)> { From 80e7b544f2927d3031630b33fdbe9c8a53f8cebc Mon Sep 17 00:00:00 2001 From: Jessie Houghton <46505805+houghj16@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:07:12 -0700 Subject: [PATCH 029/116] stabilize prompt height during toolbar changes (#332637) * chat: stabilize prompt height during toolbar changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Refactor chat input component for improved height calculation logic - Adjust height calculation to better accommodate varying input sizes - Clean up code for readability and maintainability --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/widget/input/chatInputPart.ts | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 6c12e2e64af..5e9c71cc7e3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -360,9 +360,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private _onDidLoadInputState: Emitter = this._register(new Emitter()); readonly onDidLoadInputState: Event = this._onDidLoadInputState.event; private readonly _toolbarRelayoutScheduler = this._register(new RunOnceScheduler(() => { - if (typeof this.cachedWidth === 'number') { - this.layout(this.cachedWidth); - } + this.layoutForToolbarChange(); }, 0)); private _onDidFocus = this._register(new Emitter()); @@ -422,6 +420,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private readonly inputEditorMinHeight: number | undefined; private readonly singleLineInputEditorHeight: number; private inputEditorHeight: number = 0; + private ignoreInputEditorContentSizeChanges = false; private _maxHeight: number | undefined; private container!: HTMLElement; @@ -3313,7 +3312,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._syncTextDebounced.schedule(); })); this._register(this._inputEditor.onDidContentSizeChange(e => { - if (e.contentHeightChanged) { + if (e.contentHeightChanged && !this.ignoreInputEditorContentSizeChanges) { this.inputEditorHeight = !this.inline ? e.contentHeight : this.inputEditorHeight; // Directly update editor layout - ResizeObserver will notify parent about height change if (this.cachedWidth) { @@ -3821,12 +3820,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (this.options.renderStyle === 'compact') { 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. - if (this.cachedWidth) { - this.layout(this.cachedWidth); - } + // Recalculate the shared width without changing the editor's height. + this.layoutForToolbarChange(); })); this._register(toolbarsResizeObserver.observe(toolbarsContainer)); } @@ -4794,6 +4789,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return this._layout(width); } + private layoutForToolbarChange(): void { + if (typeof this.cachedWidth === 'number') { + this._layout(this.cachedWidth, true, true); + } + } + /** * Scale the working/progress border comet animation duration with * the input width so the comet's perceived linear travel speed (the @@ -4859,7 +4860,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } private previousInputEditorDimension: IDimension | undefined; - private _layout(width: number, allowRecurse = true): void { + private _layout(width: number, allowRecurse = true, preserveInputEditorHeight = false): void { const data = this.getLayoutData(); const followupsWidth = width - data.inputPartHorizontalPadding; @@ -4868,19 +4869,27 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const initialEditorScrollWidth = this._inputEditor.getScrollWidth(); const newEditorWidth = width - data.inputPartHorizontalPadding - data.editorBorder - data.inputPartHorizontalPaddingInside - data.toolbarsWidth - data.sideToolbarWidth; const effectiveMaxHeight = this._effectiveInputEditorMaxHeight; - const clampedContentHeight = Math.min(this._inputEditor.getContentHeight(), effectiveMaxHeight); + const contentHeight = preserveInputEditorHeight && this.previousInputEditorDimension + ? this.previousInputEditorDimension.height + : this._inputEditor.getContentHeight(); + const clampedContentHeight = Math.min(contentHeight, effectiveMaxHeight); const inputEditorHeight = this.inputEditorMinHeight ? Math.min(Math.max(this.inputEditorMinHeight, clampedContentHeight), effectiveMaxHeight) : clampedContentHeight; const newDimension = { width: newEditorWidth, height: inputEditorHeight }; if (!this.previousInputEditorDimension || (this.previousInputEditorDimension.width !== newDimension.width || this.previousInputEditorDimension.height !== newDimension.height)) { // This layout call has side-effects that are hard to understand. eg if we are calling this inside a onDidChangeContent handler, this can trigger the next onDidChangeContent handler // to be invoked, and we have a lot of these on this editor. Only doing a layout this when the editor size has actually changed makes it much easier to follow. - this._inputEditor.layout(newDimension); + this.ignoreInputEditorContentSizeChanges = preserveInputEditorHeight; + try { + this._inputEditor.layout(newDimension); + } finally { + this.ignoreInputEditorContentSizeChanges = false; + } this.previousInputEditorDimension = newDimension; } if (allowRecurse && initialEditorScrollWidth < 10) { // This is probably the initial layout. Now that the editor is layed out with its correct width, it should report the correct contentHeight - return this._layout(width, false); + return this._layout(width, false, preserveInputEditorHeight); } } From fa73fe69cc9ed24f8d58f76bed26ffb8a69e1055 Mon Sep 17 00:00:00 2001 From: roblourens Date: Tue, 25 Aug 2026 17:59:52 -0700 Subject: [PATCH 030/116] chat: Honor preferred Copilot harness during session loading (#332607) chat: honor preferred Copilot harness during session loading Unify new-chat harness selection so the picker and creation paths apply the Copilot preference consistently when session navigation temporarily has no bound model. Consolidate the resolver around ServicesAccessor and cover remembered and current harness precedence.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/actions/chatActions.ts | 12 ++--- .../contrib/chat/browser/actions/chatClear.ts | 4 +- .../widgetHosts/editor/chatEditorInput.ts | 6 +-- .../widgetHosts/viewPane/chatViewPane.ts | 4 +- .../contrib/chat/common/constants.ts | 48 +++++++------------ .../chat/test/common/constants.test.ts | 31 ++++++++++-- 6 files changed, 57 insertions(+), 48 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 45aecb03b71..d957936bdfd 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -35,10 +35,7 @@ import { INotificationService } from '../../../../../platform/notification/commo import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import product from '../../../../../platform/product/common/product.js'; import { GitHubPaths, IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; -import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; -import { IAgentHostEnablementService } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { ActiveEditorContext } from '../../../../common/contextkeys.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../../../common/views.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; @@ -60,7 +57,7 @@ import { ElicitationState, IChatService, IChatToolInvocation } from '../../commo import { ISCMHistoryItemChangeRangeVariableEntry, ISCMHistoryItemChangeVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { IChatRequestViewModel, IChatResponseViewModel, isRequestVM } from '../../common/model/chatViewModel.js'; import { IChatWidgetHistoryService } from '../../common/widget/chatWidgetHistoryService.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind, getDefaultNewChatSessionTypeAndReason, resolveDefaultNewChatSessionTypeWithReason } from '../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, getDefaultNewChatSessionTypeAndReason } from '../../common/constants.js'; import { AICustomizationManagementCommands } from '../aiCustomization/aiCustomizationManagement.js'; import { ILanguageModelChatSelector, ILanguageModelsService } from '../../common/languageModels.js'; import { CopilotUsageExtensionFeatureId } from '../../common/languageModelStats.js'; @@ -71,7 +68,7 @@ import { IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; import { ChatEditorInput, showClearEditingSessionConfirmation } from '../widgetHosts/editor/chatEditorInput.js'; import { convertBufferToScreenshotVariable } from '../attachments/chatScreenshotContext.js'; import { getChatSessionType, getNewChatSessionResource } from '../../common/model/chatUri.js'; -import { IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; +import { localChatSessionType } from '../../common/chatSessionsService.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; @@ -595,8 +592,7 @@ export function registerChatActions() { * honoring the remembered harness preference and then the configured default. */ function getNewChatEditorInput(accessor: ServicesAccessor): { resource: URI; options: IChatEditorOptions } { - const agentHostEnablementService = accessor.get(IAgentHostEnablementService); - const resolved = getDefaultNewChatSessionTypeAndReason(accessor.get(IConfigurationService), accessor.get(IChatSessionsService), accessor.get(IStorageService), accessor.get(IWorkspaceContextService).getWorkspace(), agentHostEnablementService.enabled.get(), undefined, agentHostEnablementService.managedSandboxEnforced.get()); + const resolved = getDefaultNewChatSessionTypeAndReason(accessor); return { resource: getNewChatSessionResource(resolved.sessionType), options: { pinned: true, sessionTypeSelectionReason: resolved.selectionReason }, @@ -1793,7 +1789,7 @@ export async function clearChatSessionPreservingType(accessor: ServicesAccessor, const viewsService = accessor.get(IViewsService); const currentResource = widget.viewModel?.model.sessionResource; const currentSessionType = currentResource ? getChatSessionType(currentResource) : undefined; - const resolvedSessionType = resolveDefaultNewChatSessionTypeWithReason(accessor, { explicitOverride: sessionType, currentSessionType }); + const resolvedSessionType = getDefaultNewChatSessionTypeAndReason(accessor, { explicitOverride: sessionType, currentSessionType }); const newSessionType = resolvedSessionType.sessionType; if (isIChatViewViewContext(widget.viewContext)) { const view = await viewsService.openView(ChatViewId) as ChatViewPane; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatClear.ts b/src/vs/workbench/contrib/chat/browser/actions/chatClear.ts index b4e837a1cbd..35af8ee0bfc 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatClear.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatClear.ts @@ -5,7 +5,7 @@ import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; -import { IResolvedNewChatSessionType, resolveDefaultNewChatSessionTypeWithReason } from '../../common/constants.js'; +import { getDefaultNewChatSessionTypeAndReason, IResolvedNewChatSessionType } from '../../common/constants.js'; import { getChatSessionType, getNewChatSessionResource } from '../../common/model/chatUri.js'; import { IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; @@ -21,7 +21,7 @@ export async function clearChatEditor(accessor: ServicesAccessor, chatEditorInpu if (chatEditorInput instanceof ChatEditorInput) { const currentResource = chatEditorInput.sessionResource; const currentSessionType = currentResource ? getChatSessionType(currentResource) : undefined; - const resolved = resolvedSessionType ?? resolveDefaultNewChatSessionTypeWithReason(accessor, { + const resolved = resolvedSessionType ?? getDefaultNewChatSessionTypeAndReason(accessor, { currentSessionType, }); const resource = getNewChatSessionResource(resolved.sessionType); diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts index c749eea1cfd..17b87e41ad1 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts @@ -26,7 +26,7 @@ import { EditorInputCapabilities, IEditorIdentifier, IEditorSerializer, IUntyped import { EditorInput, IEditorCloseHandler } from '../../../../../common/editor/editorInput.js'; import { IChatModelReference, IChatService } from '../../../common/chatService/chatService.js'; import { IChatSessionsService, isAgentHostTarget, localChatSessionType } from '../../../common/chatSessionsService.js'; -import { ChatAgentLocation, ChatEditorTitleMaxLength, getDefaultNewChatSessionType, getDefaultNewChatSessionTypeAndReason, isNewChatSessionTypeUsable } from '../../../common/constants.js'; +import { ChatAgentLocation, ChatEditorTitleMaxLength, getDefaultNewChatSessionType, getDefaultNewChatSessionTypeAndReasonFromServices, isNewChatSessionTypeUsable } from '../../../common/constants.js'; import { IChatEditingSession, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { IChatModel } from '../../../common/model/chatModel.js'; import { LocalChatSessionUri, getChatSessionType, getNewChatSessionResource, isUntitledChatSession } from '../../../common/model/chatUri.js'; @@ -271,7 +271,7 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler } if (this.shouldReplaceEmptyLocalSession(this._sessionResource)) { - const defaultTypeAndReason = getDefaultNewChatSessionTypeAndReason(this.configurationService, this.chatSessionsService, this.storageService, this.workspaceContextService.getWorkspace(), this.agentHostEnablementService.enabled.get(), undefined, this.agentHostEnablementService.managedSandboxEnforced.get()); + const defaultTypeAndReason = getDefaultNewChatSessionTypeAndReasonFromServices(this.configurationService, this.chatSessionsService, this.storageService, this.workspaceContextService.getWorkspace(), this.agentHostEnablementService.enabled.get(), undefined, this.agentHostEnablementService.managedSandboxEnforced.get()); const defaultResource = getNewChatSessionResource(defaultTypeAndReason.sessionType); if (getChatSessionType(defaultResource) !== localChatSessionType) { let modelRef: IChatModelReference | undefined; @@ -297,7 +297,7 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler if (this.options.explicitSessionType === localChatSessionType) { this.modelRef.value = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { canUseTools: !inputType, debugOwner: 'ChatEditorInput#resolveExplicitLocal', sessionTypeSelectionReason: this.options.sessionTypeSelectionReason ?? 'explicitOverride' }); } else { - const defaultTypeAndReason = getDefaultNewChatSessionTypeAndReason(this.configurationService, this.chatSessionsService, this.storageService, this.workspaceContextService.getWorkspace(), this.agentHostEnablementService.enabled.get(), undefined, this.agentHostEnablementService.managedSandboxEnforced.get()); + const defaultTypeAndReason = getDefaultNewChatSessionTypeAndReasonFromServices(this.configurationService, this.chatSessionsService, this.storageService, this.workspaceContextService.getWorkspace(), this.agentHostEnablementService.enabled.get(), undefined, this.agentHostEnablementService.managedSandboxEnforced.get()); const defaultResource = getNewChatSessionResource(defaultTypeAndReason.sessionType); if (getChatSessionType(defaultResource) === localChatSessionType) { this.modelRef.value = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { canUseTools: !inputType, debugOwner: 'ChatEditorInput#resolveUntitled', sessionTypeSelectionReason: defaultTypeAndReason.selectionReason }); diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 2d35076e5af..6d5bfafe873 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -55,7 +55,7 @@ import { CHAT_PROVIDER_ID } from '../../../common/participants/chatParticipantCo import { IChatModelReference, IChatService } from '../../../common/chatService/chatService.js'; import { IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; import { LocalChatSessionUri, getChatSessionType, getNewChatSessionResource, isUntitledChatSession } from '../../../common/model/chatUri.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind, getDefaultNewChatSessionType, getDefaultNewChatSessionTypeAndReason, SessionTypeSelectionReason } from '../../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, getDefaultNewChatSessionType, getDefaultNewChatSessionTypeAndReasonFromServices, SessionTypeSelectionReason } from '../../../common/constants.js'; import { AgentSessionsControl } from '../../agentSessions/agentSessionsControl.js'; import { ACTION_ID_NEW_CHAT } from '../../actions/chatActions.js'; import { ChatWidget, layoutChatWidgetForInputHeight } from '../../widget/chatWidget.js'; @@ -1303,7 +1303,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { */ private async acquireDefaultNewSession(token: CancellationToken): Promise { const workspace = this.workspaceContextService.getWorkspace(); - const defaultTypeAndReason = getDefaultNewChatSessionTypeAndReason(this.configurationService, this.chatSessionsService, this.storageService, workspace, this.agentHostEnablementService.enabled.get(), undefined, this.agentHostEnablementService.managedSandboxEnforced.get()); + const defaultTypeAndReason = getDefaultNewChatSessionTypeAndReasonFromServices(this.configurationService, this.chatSessionsService, this.storageService, workspace, this.agentHostEnablementService.enabled.get(), undefined, this.agentHostEnablementService.managedSandboxEnforced.get()); if (defaultTypeAndReason.sessionType === localChatSessionType) { return this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { debugOwner: 'ChatViewPane#acquireDefaultNewSession', sessionTypeSelectionReason: defaultTypeAndReason.selectionReason }); } diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 4a8faec9f2f..e18e401c9b6 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -394,10 +394,10 @@ export function getDefaultNewChatSessionType( options?: IDefaultNewChatSessionTypeOptions, managedSandboxEnforced = false ): string { - return getDefaultNewChatSessionTypeAndReason(configurationService, chatSessionsService, storageService, workspace, agentHostEnabled, options, managedSandboxEnforced).sessionType; + return getDefaultNewChatSessionTypeAndReasonFromServices(configurationService, chatSessionsService, storageService, workspace, agentHostEnabled, options, managedSandboxEnforced).sessionType; } -export function getDefaultNewChatSessionTypeAndReason( +export function getDefaultNewChatSessionTypeAndReasonFromServices( configurationService: IConfigurationService, chatSessionsService: Pick, storageService: IStorageService, @@ -414,19 +414,30 @@ export function getDefaultNewChatSessionTypeAndReason( return { sessionType: localChatSessionType, selectionReason: 'virtualWorkspace' }; } + const preferCopilotHarness = agentHostEnabled && isCopilotHarnessPreferred(configurationService, managedSandboxEnforced); const remembered = getUsableRememberedSessionType(storageService, configurationService, chatSessionsService, workspace, agentHostEnabled, managedSandboxEnforced); - if (remembered) { + if (remembered && (remembered !== localChatSessionType || !preferCopilotHarness)) { return { sessionType: remembered, selectionReason: 'rememberedSelection' }; } + let resolved: IResolvedNewChatSessionType; if (options?.currentSessionType && isNewChatSessionTypeUsable(options.currentSessionType, configurationService, chatSessionsService, workspace, agentHostEnabled, managedSandboxEnforced)) { - return { sessionType: options.currentSessionType, selectionReason: 'currentSession' }; + resolved = { sessionType: options.currentSessionType, selectionReason: 'currentSession' }; + } else if (remembered) { + resolved = { sessionType: remembered, selectionReason: 'rememberedSelection' }; + } else { + resolved = { + sessionType: getComputedDefaultSessionType(configurationService, chatSessionsService, workspace, agentHostEnabled, managedSandboxEnforced), + selectionReason: 'computedDefault' + }; } - return { sessionType: getComputedDefaultSessionType(configurationService, chatSessionsService, workspace, agentHostEnabled, managedSandboxEnforced), selectionReason: 'computedDefault' }; + return resolved.sessionType === localChatSessionType && preferCopilotHarness + ? { sessionType: SessionType.AgentHostCopilot, selectionReason: 'copilotPreference' } + : resolved; } -export function resolveDefaultNewChatSessionTypeWithReason( +export function getDefaultNewChatSessionTypeAndReason( accessor: ServicesAccessor, options?: IDefaultNewChatSessionTypeOptions ): IResolvedNewChatSessionType { @@ -438,30 +449,7 @@ export function resolveDefaultNewChatSessionTypeWithReason( const agentHostEnabled = agentHostEnablementService.enabled.get(); const managedSandboxEnforced = agentHostEnablementService.managedSandboxEnforced.get(); - if (options?.explicitOverride) { - return { sessionType: options.explicitOverride, selectionReason: 'explicitOverride' }; - } - - if (isVirtualWorkspace(workspace)) { - return { sessionType: localChatSessionType, selectionReason: 'virtualWorkspace' }; - } - - const remembered = getUsableRememberedSessionType(storageService, configurationService, chatSessionsService, workspace, agentHostEnabled, managedSandboxEnforced); - if (remembered && remembered !== localChatSessionType) { - return { sessionType: remembered, selectionReason: 'rememberedSelection' }; - } - - if (options?.currentSessionType === localChatSessionType - && agentHostEnabled - && isCopilotHarnessPreferred(configurationService, managedSandboxEnforced)) { - return { sessionType: SessionType.AgentHostCopilot, selectionReason: 'copilotPreference' }; - } - - return getDefaultNewChatSessionTypeAndReason(configurationService, chatSessionsService, storageService, workspace, agentHostEnabled, options, managedSandboxEnforced); -} - -export function resolveDefaultNewChatSessionType(accessor: ServicesAccessor, options?: IDefaultNewChatSessionTypeOptions): { readonly sessionType: string } { - return { sessionType: resolveDefaultNewChatSessionTypeWithReason(accessor, options).sessionType }; + return getDefaultNewChatSessionTypeAndReasonFromServices(configurationService, chatSessionsService, storageService, workspace, agentHostEnabled, options, managedSandboxEnforced); } function getUsableRememberedSessionType( diff --git a/src/vs/workbench/contrib/chat/test/common/constants.test.ts b/src/vs/workbench/contrib/chat/test/common/constants.test.ts index 8610dd061da..15d6b460b2d 100644 --- a/src/vs/workbench/contrib/chat/test/common/constants.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/constants.test.ts @@ -13,7 +13,7 @@ import { TestConfigurationService } from '../../../../../platform/configuration/ import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { IWorkspaceContextService, Workspace, toWorkspaceFolder } from '../../../../../platform/workspace/common/workspace.js'; -import { ChatConfiguration, ChatPermissionLevel, getChatPermissionLevelFromDefaultConfiguration, getComputedDefaultSessionResource, getComputedDefaultSessionType, getDefaultNewChatSessionResource, getDefaultNewChatSessionType, IDefaultNewChatSessionTypeOptions, isEditorLocalAgentEnabled, isNewChatSessionTypeUsable, isVisibleEditorChatSessionType, recordUserSelectedSessionType, resolveDefaultNewChatSessionType, resolveDefaultNewChatSessionTypeWithReason } from '../../common/constants.js'; +import { ChatConfiguration, ChatPermissionLevel, getChatPermissionLevelFromDefaultConfiguration, getComputedDefaultSessionResource, getComputedDefaultSessionType, getDefaultNewChatSessionResource, getDefaultNewChatSessionType, getDefaultNewChatSessionTypeAndReason, IDefaultNewChatSessionTypeOptions, isEditorLocalAgentEnabled, isNewChatSessionTypeUsable, isVisibleEditorChatSessionType, recordUserSelectedSessionType } from '../../common/constants.js'; import { localChatSessionType, SessionType, IChatSessionsExtensionPoint, IChatSessionsService } from '../../common/chatSessionsService.js'; import { MockChatSessionsService } from './mockChatSessionsService.js'; import { TestContextService, TestStorageService } from '../../../../test/common/workbenchTestServices.js'; @@ -60,7 +60,7 @@ suite('ChatConfiguration defaults', () => { accessor.set(IStorageService, storageService); accessor.set(IWorkspaceContextService, new TestContextService(workspace)); accessor.set(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(agentHostEnabled), managedSandboxEnforced: constObservable(false) }); - return resolveDefaultNewChatSessionType(accessor, options); + return { sessionType: getDefaultNewChatSessionTypeAndReason(accessor, options).sessionType }; } function resolveSessionTypeWithReason( @@ -77,7 +77,7 @@ suite('ChatConfiguration defaults', () => { accessor.set(IStorageService, storageService); accessor.set(IWorkspaceContextService, new TestContextService(workspace)); accessor.set(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(agentHostEnabled), managedSandboxEnforced: constObservable(false) }); - return resolveDefaultNewChatSessionTypeWithReason(accessor, options); + return getDefaultNewChatSessionTypeAndReason(accessor, options); } test('default permission configuration maps setting values to Agent Host values', () => { @@ -296,9 +296,13 @@ suite('ChatConfiguration defaults', () => { const storageService = disposables.add(new TestStorageService()); assert.deepStrictEqual({ + pickerFallback: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, localWorkspace, true), + directCurrent: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, localWorkspace, true, { currentSessionType: localChatSessionType }), firstResolve: resolveSessionType(configurationService, chatSessionsService, storageService, localWorkspace, true, { currentSessionType: localChatSessionType }), secondResolve: resolveSessionType(configurationService, chatSessionsService, storageService, localWorkspace, true, { currentSessionType: localChatSessionType }), }, { + pickerFallback: SessionType.AgentHostCopilot, + directCurrent: SessionType.AgentHostCopilot, firstResolve: { sessionType: SessionType.AgentHostCopilot }, secondResolve: { sessionType: SessionType.AgentHostCopilot }, }); @@ -402,9 +406,30 @@ suite('ChatConfiguration defaults', () => { assert.deepStrictEqual({ firstResolve: resolveSessionType(configurationService, chatSessionsService, storageService, localWorkspace, true, { currentSessionType: localChatSessionType }), secondResolve: resolveSessionType(configurationService, chatSessionsService, storageService, localWorkspace, true, { currentSessionType: localChatSessionType }), + pickerFallback: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, localWorkspace, true), }, { firstResolve: { sessionType: SessionType.AgentHostCopilot }, secondResolve: { sessionType: SessionType.AgentHostCopilot }, + pickerFallback: SessionType.AgentHostCopilot, + }); + }); + + test('Copilot preference preserves the current non-local harness over remembered local', () => { + const configurationService = new TestConfigurationService({ + [ChatConfiguration.DefaultToCopilotHarness]: true, + [ChatConfiguration.EditorPreferCopilotHarness]: true, + }); + const chatSessionsService = createChatSessionsService(SessionType.AgentHostCopilot, SessionType.AgentHostClaude); + const storageService = disposables.add(new TestStorageService()); + + recordUserSelectedSessionType(storageService, configurationService, chatSessionsService, localWorkspace, localChatSessionType, true); + + assert.deepStrictEqual({ + direct: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, localWorkspace, true, { currentSessionType: SessionType.AgentHostClaude }), + resolved: resolveSessionType(configurationService, chatSessionsService, storageService, localWorkspace, true, { currentSessionType: SessionType.AgentHostClaude }), + }, { + direct: SessionType.AgentHostClaude, + resolved: { sessionType: SessionType.AgentHostClaude }, }); }); From 4470c4eb3293c6b2792d1ccbb0df5a966ac4ef83 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Wed, 26 Aug 2026 03:00:25 +0200 Subject: [PATCH 031/116] Enforce stable link presentation provider kinds (#332519) * Enforce stable link presentation provider kinds Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8d0d714f-06c2-4399-83ba-5016fae514f5 * Fix link presentation CI failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8d0d714f-06c2-4399-83ba-5016fae514f5 * Register GitHub contribution for localization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8d0d714f-06c2-4399-83ba-5016fae514f5 * Remove Markdown GitHub link presentations Keep GitHub repository, issue, and pull request presentations in core only. Address review feedback around provider re-registration, cache kind validation, check statuses, fixture kinds, and optional repository fields. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8d0d714f-06c2-4399-83ba-5016fae514f5 * Fix default account event test type Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8d0d714f-06c2-4399-83ba-5016fae514f5 * Fix default account type import Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8d0d714f-06c2-4399-83ba-5016fae514f5 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8d0d714f-06c2-4399-83ba-5016fae514f5 --- build/lib/i18n.resources.json | 4 + .../markdown-editor-src/editor.ts | 2 +- .../linkPresentationProvider.ts | 8 +- .../markdown-language-features/package.json | 13 +- .../src/extension.shared.ts | 2 +- .../markdown-language-features/src/logging.ts | 8 +- .../gitLinkPresentationResolver.ts | 41 +- .../githubLinkPresentationResolver.ts | 543 ------------------ .../linkPresentationBuilders.ts | 152 +++++ .../linkPresentationResolver.ts | 186 ------ .../linkPresentationService.ts | 33 +- .../workspaceLinkPresentationResolver.ts | 77 +-- .../src/preview/markdownEditorProvider.ts | 4 +- .../src/preview/webviewInitialState.ts | 2 +- .../src/test/markdownEditorRichLinks.test.ts | 173 +----- .../agentHost/common/openSessionLink.ts | 4 +- .../test/common/openSessionLink.test.ts | 20 +- .../dataChannel/common/dataChannel.ts | 4 +- .../github/common/githubQueryService.ts | 7 + .../github/common/githubQueryServiceImpl.ts | 249 +++++++- .../test/node/githubQueryService.test.ts | 146 +++++ .../openSessionLinkOpener.contribution.ts | 23 +- .../api/browser/mainThreadDataChannels.ts | 8 +- .../workbench/api/common/extHost.protocol.ts | 4 +- .../api/common/extHostDataChannels.ts | 16 +- .../browser/mainThreadDataChannels.test.ts | 208 ++++++- .../openSessionLinkOpener.contribution.ts | 6 +- .../chatMarkdownContentPart.test.ts | 4 +- .../githubLinkPresentation.contribution.ts | 424 ++++++++++++++ .../browser/githubLinkPresentation.test.ts | 266 +++++++++ .../dataChannel/browser/dataChannelService.ts | 75 ++- .../test/browser/dataChannelService.test.ts | 6 +- .../services/github/browser/githubService.ts | 90 +++ .../chat/chatRichLink.fixture.ts | 105 ++-- .../chat/linkPresentationBuilders.ts | 290 ++++++++++ src/vs/workbench/workbench.common.main.ts | 2 + .../vscode.proposed.linkPresentation.d.ts | 4 +- 37 files changed, 2083 insertions(+), 1126 deletions(-) delete mode 100644 extensions/markdown-language-features/src/preview/linkPresentation/githubLinkPresentationResolver.ts create mode 100644 extensions/markdown-language-features/src/preview/linkPresentation/linkPresentationBuilders.ts create mode 100644 src/vs/workbench/contrib/github/browser/githubLinkPresentation.contribution.ts create mode 100644 src/vs/workbench/contrib/github/test/browser/githubLinkPresentation.test.ts create mode 100644 src/vs/workbench/services/github/browser/githubService.ts create mode 100644 src/vs/workbench/test/browser/componentFixtures/chat/linkPresentationBuilders.ts diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index 058938ee2b8..ca81ac67daa 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -110,6 +110,10 @@ "name": "vs/workbench/contrib/folding", "project": "vscode-workbench" }, + { + "name": "vs/workbench/contrib/github", + "project": "vscode-workbench" + }, { "name": "vs/workbench/contrib/html", "project": "vscode-workbench" diff --git a/extensions/markdown-language-features/markdown-editor-src/editor.ts b/extensions/markdown-language-features/markdown-editor-src/editor.ts index 5556dc2bc3e..d2287cac278 100644 --- a/extensions/markdown-language-features/markdown-editor-src/editor.ts +++ b/extensions/markdown-language-features/markdown-editor-src/editor.ts @@ -44,7 +44,7 @@ interface InitialState { readonly documentVersion: number; readonly readonly: boolean; readonly richLinksEnabled: boolean; - readonly linkPresentationRules: readonly { id: string; source: string; flags: string; initialKind: LinkPresentationKind }[]; + readonly linkPresentationRules: readonly { id: string; source: string; flags: string; kind: LinkPresentationKind }[]; } class Editor extends Disposable { diff --git a/extensions/markdown-language-features/markdown-editor-src/linkPresentationProvider.ts b/extensions/markdown-language-features/markdown-editor-src/linkPresentationProvider.ts index 29e1e5117fe..3991b19d633 100644 --- a/extensions/markdown-language-features/markdown-editor-src/linkPresentationProvider.ts +++ b/extensions/markdown-language-features/markdown-editor-src/linkPresentationProvider.ts @@ -21,19 +21,19 @@ type WebviewLinkPresentation = LinkPresentation & { readonly isLoading?: boolean export class WebviewLinkPresentationProvider extends Disposable implements ILinkPresentationProvider { readonly #entries = new Map(); - readonly #rules: readonly { id: string; uriPattern: RegExp; initialKind: LinkPresentationKind }[]; + readonly #rules: readonly { id: string; uriPattern: RegExp; kind: LinkPresentationKind }[]; readonly #postMessage: (message: unknown) => void; #syncScheduled = false; constructor( - rules: readonly { id: string; source: string; flags: string; initialKind: LinkPresentationKind }[], + rules: readonly { id: string; source: string; flags: string; kind: LinkPresentationKind }[], postMessage: (message: unknown) => void, ) { super(); this.#rules = rules.map(rule => ({ id: rule.id, uriPattern: new RegExp(rule.source, rule.flags), - initialKind: rule.initialKind, + kind: rule.kind, })); this.#postMessage = postMessage; } @@ -48,7 +48,7 @@ export class WebviewLinkPresentationProvider extends Disposable implements ILink if (!entry) { entry = { presentation: observableValue(`linkPresentation:${url}`, { - kind: rule.initialKind, + kind: rule.kind, isLoading: true, }), references: 0, diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 7abc46a34ce..ca96b6345b8 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -47,9 +47,14 @@ "contributes": { "linkPresentationProviders": [ { - "id": "markdown.linkPresentations", - "initialKind": "resource", - "uriPattern": "^(?:(?:file|vscode-remote|vscode-vfs):[^?#]*|commit:[^?#]+|https?://[^\\s?#]+/(?:commit|-/commit)/[^/?#]+|https://github\\.com/[^/?#]+/[^/?#]+(?:/(?:issues/[^/?#]+|pull/[^/?#]+|tree/[^?#]+|blob/[^?#]+))?|(?!(?:[a-z][a-z0-9+.-]*:|#))[^?#]+)(?:[?#].*)?$" + "id": "markdown.gitCommitLinkPresentations", + "kind": "commit", + "uriPattern": "^(?:commit:[^?#]+|https?://[^\\s?#]+/(?:commit|-/commit)/[^/?#]+)(?:[?#].*)?$" + }, + { + "id": "markdown.workspaceFileLinkPresentations", + "kind": "file", + "uriPattern": "^(?:(?:file|vscode-remote|vscode-vfs):[^?#]*|(?!(?:[a-z][a-z0-9+.-]*:|#))[^?#]+)(?:[?#].*)?$" } ], "notebookRenderer": [ @@ -1271,7 +1276,7 @@ "properties": { "markdown.experimental.richLinks.enabled": { "type": "boolean", - "default": false, + "default": true, "description": "%configuration.markdown.experimental.richLinks.enabled%", "scope": "window", "tags": [ diff --git a/extensions/markdown-language-features/src/extension.shared.ts b/extensions/markdown-language-features/src/extension.shared.ts index ff618786901..85564580e54 100644 --- a/extensions/markdown-language-features/src/extension.shared.ts +++ b/extensions/markdown-language-features/src/extension.shared.ts @@ -47,7 +47,7 @@ export function activateShared( context.subscriptions.push(registerMarkdownLanguageFeatures(client, commandManager, engine)); context.subscriptions.push(registerMarkdownCommands(commandManager, previewManager, telemetryReporter, cspArbiter, engine)); - const linkPresentationService = createSharedLinkPresentationService(context.globalState, logger); + const linkPresentationService = createSharedLinkPresentationService(logger); context.subscriptions.push( linkPresentationService, registerLinkPresentationProvider(linkPresentationService), diff --git a/extensions/markdown-language-features/src/logging.ts b/extensions/markdown-language-features/src/logging.ts index 20636643ec1..3364a06ca4b 100644 --- a/extensions/markdown-language-features/src/logging.ts +++ b/extensions/markdown-language-features/src/logging.ts @@ -24,6 +24,12 @@ export class VsCodeOutputLogger extends Disposable implements ILogger { } public trace(title: string, message: string, data?: unknown): void { - this.#outputChannel.trace(`${title}: ${message}`, ...(data ? [JSON.stringify(data, null, 4)] : [])); + if (!this.#outputChannelValue && vscode.env.logLevel !== vscode.LogLevel.Trace) { + return; + } + const outputChannel = this.#outputChannel; + if (outputChannel.logLevel === vscode.LogLevel.Trace) { + outputChannel.trace(`${title}: ${message}`, ...(data ? [JSON.stringify(data, null, 4)] : [])); + } } } diff --git a/extensions/markdown-language-features/src/preview/linkPresentation/gitLinkPresentationResolver.ts b/extensions/markdown-language-features/src/preview/linkPresentation/gitLinkPresentationResolver.ts index 6e6a871a303..87c1753f3e5 100644 --- a/extensions/markdown-language-features/src/preview/linkPresentation/gitLinkPresentationResolver.ts +++ b/extensions/markdown-language-features/src/preview/linkPresentation/gitLinkPresentationResolver.ts @@ -6,6 +6,7 @@ import type { LinkPresentation } from '@vscode/markdown-editor'; import type { IObservable } from '@vscode/observables'; import * as vscode from 'vscode'; +import { buildGitCommitLookupFailurePresentation, buildGitCommitPresentation, buildLoadingLinkPresentation, type GitCommitPresentationData } from './linkPresentationBuilders'; import { createAsyncLinkPresentation, decodeUrlPathSegments, ImmutableLinkPresentationCache, type LinkPresentationResolver, type LinkPresentationResolverContext } from './linkPresentationResolver'; export class GitLinkPresentationResolver implements LinkPresentationResolver { @@ -28,18 +29,13 @@ export class GitLinkPresentationResolver implements LinkPresentationResolver { return createAsyncLinkPresentation( href, - { - kind: 'commit', - status: { kind: 'pending', label: 'Loading' }, - }, + buildLoadingLinkPresentation('commit'), context, () => this.#cache.get(href, () => this.#resolve(target)), - error => ({ - kind: 'commit', - status: { kind: 'error', label: 'Not available' }, - tooltip: error instanceof Error ? error.message : 'The Git commit could not be resolved.', - ariaLabel: `Git commit ${target.sha.slice(0, 7)} could not be resolved`, - }), + error => buildGitCommitLookupFailurePresentation( + target.sha.slice(0, 7), + error instanceof Error ? error.message : 'The Git commit could not be resolved.', + ), [context.onDidRequestRefresh, this.#onDidChangeRepositories.event], ); } @@ -80,7 +76,7 @@ export class GitLinkPresentationResolver implements LinkPresentationResolver { } async #resolve(target: GitCommitTarget): Promise { - return getGitCommitPresentation((await this.#findCommit(target)).commit); + return buildGitCommitPresentation((await this.#findCommit(target)).commit); } async #findCommit(target: GitCommitTarget): Promise { @@ -224,30 +220,11 @@ interface GitRemote { readonly pushUrl?: string; } -interface GitCommit { - readonly hash: string; - readonly message: string; - readonly shortStat?: { - readonly insertions: number; - readonly deletions: number; - }; -} +interface GitCommit extends GitCommitPresentationData { } interface GitCommitResult { readonly repository: GitRepository; readonly commit: GitCommit; } -export function getGitCommitPresentation(commit: GitCommit): LinkPresentation { - const title = commit.message.split(/\r?\n/, 1)[0]; - const insertions = commit.shortStat?.insertions ?? 0; - const deletions = commit.shortStat?.deletions ?? 0; - const shortHash = commit.hash.slice(0, 7); - return { - kind: 'commit', - detail: title, - // TODO: Include insertion and deletion counts once the Markdown editor package supports them. - tooltip: `${shortHash} · ${title} · ${insertions} insertions, ${deletions} deletions`, - ariaLabel: `Commit ${shortHash}, ${insertions} insertions and ${deletions} deletions: ${title}`, - }; -} +export { buildGitCommitPresentation as getGitCommitPresentation } from './linkPresentationBuilders'; diff --git a/extensions/markdown-language-features/src/preview/linkPresentation/githubLinkPresentationResolver.ts b/extensions/markdown-language-features/src/preview/linkPresentation/githubLinkPresentationResolver.ts deleted file mode 100644 index 198cad0fd63..00000000000 --- a/extensions/markdown-language-features/src/preview/linkPresentation/githubLinkPresentationResolver.ts +++ /dev/null @@ -1,543 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import type { LinkPresentationStatus } from '@vscode/markdown-editor'; -import type { IObservable } from '@vscode/observables'; -import * as vscode from 'vscode'; -import { createAsyncLinkPresentation, decodeUrlPathSegments, LinkPresentationCache, type LinkPresentation, type LinkPresentationResolver, type LinkPresentationResolverContext } from './linkPresentationResolver'; - -const githubRepositoryScope = 'repo'; - -export class GitHubLinkPresentationResolver implements LinkPresentationResolver { - readonly refreshOnInterval = true; - readonly #cache: LinkPresentationCache; - readonly #onDidChangeAuthentication = new vscode.EventEmitter(); - readonly #authenticationSubscription: vscode.Disposable; - #accessToken: Promise | undefined; - - constructor(cache: LinkPresentationCache) { - this.#cache = cache; - this.#authenticationSubscription = vscode.authentication.onDidChangeSessions(event => { - if (event.provider.id === 'github') { - this.#accessToken = undefined; - this.#cache.clear(); - this.#onDidChangeAuthentication.fire(); - } - }); - } - - resolve(href: string, context: LinkPresentationResolverContext): IObservable | undefined { - const target = parseGitHubTarget(href); - if (!target) { - return undefined; - } - const persisted = this.#cache.getPersisted(href); - const loadingPresentation: LinkPresentation = { - kind: target.kind === 'tree' ? 'resource' : target.kind, - status: { kind: 'pending', label: 'Loading' }, - }; - return createAsyncLinkPresentation( - href, - persisted ?? loadingPresentation, - context, - () => this.#cache.get(href, () => this.#resolve(target)), - error => getGitHubLookupFailurePresentationForTarget(target, error), - [context.onDidRequestRefresh], - { event: this.#onDidChangeAuthentication.event, presentation: loadingPresentation }, - ); - } - - dispose(): void { - this.#authenticationSubscription.dispose(); - this.#onDidChangeAuthentication.dispose(); - } - - async #resolve(target: GitHubTarget): Promise { - const request = new GitHubRequest(await this.#getAccessToken()); - switch (target.kind) { - case 'issue': { - const issue = await request.get(`/repos/${target.owner}/${target.repository}/issues/${target.number}`, readIssue); - const state = getGitHubIssueStatus(issue.state, issue.stateReason); - return { - kind: 'issue', - title: issue.title, - reference: `#${target.number}`, - status: state, - tooltip: `${target.owner}/${target.repository}#${target.number} · ${state.label}`, - ariaLabel: `Issue ${target.owner} slash ${target.repository} number ${target.number}, ${state.label}: ${issue.title}`, - }; - } - case 'pullRequest': { - const pullRequest = await request.get(`/repos/${target.owner}/${target.repository}/pulls/${target.number}`, readPullRequest); - const state = getGitHubPullRequestStatus(pullRequest.state, pullRequest.draft, pullRequest.merged); - const checksStatus = shouldShowGitHubPullRequestChecks(state) - ? checkRunStatus(await request.getAll( - `/repos/${target.owner}/${target.repository}/commits/${encodeURIComponent(pullRequest.headSha)}/check-runs`, - readCheckRuns, - )) - : undefined; - return { - kind: 'pullRequest', - title: pullRequest.title, - reference: `#${target.number}`, - status: state, - ...(checksStatus ? { secondaryStatus: checksStatus } : {}), - tooltip: [target.owner + '/' + target.repository + '#' + target.number, state.label, checksStatus?.label].filter(Boolean).join(' · '), - ariaLabel: `Pull request ${target.owner} slash ${target.repository} number ${target.number}, ${state.label}${checksStatus ? `, ${checksStatus.label}` : ''}: ${pullRequest.title}`, - }; - } - case 'repository': { - const repository = await request.get(`/repos/${target.owner}/${target.repository}`, readRepository); - const details = [ - repository.language, - repository.stars === undefined ? undefined : `${formatCount(repository.stars)} stars`, - ].filter((value): value is string => !!value); - return { - kind: 'repository', - ...(details.length ? { detail: details.join(' · ') } : {}), - tooltip: `${target.owner}/${target.repository}`, - ariaLabel: `GitHub repository ${target.owner} slash ${target.repository}`, - }; - } - case 'tree': { - const refs = await request.get( - `/repos/${target.owner}/${target.repository}/git/matching-refs/heads/${encodeURIComponent(target.segments[0])}`, - readGitRefs, - ); - const tree = resolveGitHubTreePath(target.segments, refs); - if (!tree) { - throw new GitHubLookupError('notFound', `GitHub did not find branch ${target.segments.join('/')}.`); - } - if (tree.path) { - return { - kind: 'folder', - detail: `${target.owner}/${target.repository} · ${tree.path}`, - tooltip: target.href, - ariaLabel: `Folder ${tree.path} in ${target.owner} slash ${target.repository}`, - }; - } - const branch = await request.get( - `/repos/${target.owner}/${target.repository}/branches/${encodeURIComponent(tree.branch)}`, - readBranch, - ); - return { - kind: 'branch', - detail: branch.sha.slice(0, 7), - tooltip: `${target.owner}/${target.repository} · ${tree.branch}`, - ariaLabel: `Branch ${tree.branch} in ${target.owner} slash ${target.repository}`, - }; - } - case 'file': - return { - kind: 'file', - detail: `${target.owner}/${target.repository} · ${target.path}`, - tooltip: target.href, - ariaLabel: `File ${target.path} in ${target.owner} slash ${target.repository}`, - }; - } - } - - #getAccessToken(): Promise { - if (this.#accessToken) { - return this.#accessToken; - } - const value = this.#readAccessToken().catch(error => { - if ( - this.#accessToken === value - && !(error instanceof GitHubLookupError && error.kind === 'authenticationRequired') - ) { - this.#accessToken = undefined; - } - throw error; - }); - this.#accessToken = value; - return value; - } - - async #readAccessToken(): Promise { - try { - const accounts = await vscode.authentication.getAccounts('github'); - for (const account of accounts) { - const session = await vscode.authentication.getSession('github', [], { silent: true, account }); - if (session?.scopes.includes(githubRepositoryScope)) { - return session.accessToken; - } - } - const session = await vscode.authentication.getSession('github', [githubRepositoryScope], { - createIfNone: { - detail: 'The Markdown editor needs repository access to show issue, pull request, and CI status.', - }, - ...(accounts.length === 1 ? { account: accounts[0] } : {}), - }); - return session.accessToken; - } catch (error) { - throw new GitHubLookupError( - 'authenticationRequired', - `GitHub repository access was not authorized.${error instanceof Error ? ` ${error.message}` : ''}`, - ); - } - } -} - -class GitHubRequest { - readonly #accessToken: string; - - constructor(accessToken: string) { - this.#accessToken = accessToken; - } - - async get(apiPath: string, read: (value: unknown) => T | undefined): Promise { - const response = await fetch(`https://api.github.com${apiPath}`, { - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${this.#accessToken}`, - }, - }); - if (!response.ok) { - throw GitHubLookupError.fromResponse(apiPath, response); - } - const value = read(await response.json()); - if (!value) { - throw new GitHubLookupError( - 'invalidResponse', - `GitHub request ${apiPath} returned an unexpected response.`, - ); - } - return value; - } - - async getAll(apiPath: string, read: (value: unknown) => readonly T[] | undefined): Promise { - const values: T[] = []; - const pageSize = 100; - for (let page = 1; ; page++) { - const separator = apiPath.includes('?') ? '&' : '?'; - const pageValues = await this.get(`${apiPath}${separator}per_page=${pageSize}&page=${page}`, read); - values.push(...pageValues); - if (pageValues.length < pageSize) { - return values; - } - } - } -} - -type GitHubLookupFailureKind = - | 'authenticationRequired' - | 'authenticationFailed' - | 'accessDenied' - | 'rateLimited' - | 'notFound' - | 'invalidResponse' - | 'requestFailed'; - -export class GitHubLookupError extends Error { - readonly kind: GitHubLookupFailureKind; - - constructor(kind: GitHubLookupFailureKind, message: string) { - super(message); - this.name = 'GitHubLookupError'; - this.kind = kind; - } - - static fromResponse(apiPath: string, response: Response): GitHubLookupError { - const message = `GitHub request ${apiPath} failed: ${response.status} ${response.statusText}`; - if (response.status === 401) { - return new GitHubLookupError('authenticationFailed', message); - } - if (response.status === 403) { - return new GitHubLookupError( - response.headers.get('x-ratelimit-remaining') === '0' ? 'rateLimited' : 'accessDenied', - message, - ); - } - if (response.status === 404) { - return new GitHubLookupError('notFound', message); - } - return new GitHubLookupError('requestFailed', message); - } -} - -type GitHubTarget = - | { readonly kind: 'issue'; readonly href: string; readonly owner: string; readonly repository: string; readonly number: number } - | { readonly kind: 'pullRequest'; readonly href: string; readonly owner: string; readonly repository: string; readonly number: number } - | { readonly kind: 'repository'; readonly href: string; readonly owner: string; readonly repository: string } - | { readonly kind: 'tree'; readonly href: string; readonly owner: string; readonly repository: string; readonly segments: readonly [string, ...string[]] } - | { readonly kind: 'file'; readonly href: string; readonly owner: string; readonly repository: string; readonly path: string }; - -function parseGitHubTarget(href: string): GitHubTarget | undefined { - let uri: URL; - try { - uri = new URL(href); - } catch { - return undefined; - } - if (uri.protocol !== 'https:' || uri.hostname.toLowerCase() !== 'github.com') { - return undefined; - } - const segments = decodeUrlPathSegments(uri); - if (!segments) { - return undefined; - } - const [owner, repository, category, identifier, ...rest] = segments; - if (!owner || !repository) { - return undefined; - } - if (!category) { - return { kind: 'repository', href, owner, repository }; - } - if (category === 'issues' || category === 'pull') { - const number = Number(identifier); - return Number.isInteger(number) && number > 0 - ? { kind: category === 'issues' ? 'issue' : 'pullRequest', href, owner, repository, number } - : undefined; - } - if (category === 'tree' && identifier) { - return { kind: 'tree', href, owner, repository, segments: [identifier, ...rest] }; - } - if (category === 'blob' && identifier && rest.length) { - return { kind: 'file', href, owner, repository, path: rest.join('/') }; - } - return undefined; -} - -export function getGitHubLookupFailurePresentation( - href: string, - error: unknown, -): LinkPresentation | undefined { - const target = parseGitHubTarget(href); - if (!target) { - return undefined; - } - return getGitHubLookupFailurePresentationForTarget(target, error); -} - -function getGitHubLookupFailurePresentationForTarget( - target: GitHubTarget, - error: unknown, -): LinkPresentation { - const failure = error instanceof GitHubLookupError - ? githubLookupFailureDescription(error.kind) - : { label: 'Lookup failed', detail: 'The GitHub request could not be completed.' }; - const kind = target.kind === 'tree' ? 'resource' : target.kind; - return { - kind, - status: { kind: 'error', label: failure.label }, - tooltip: `${failure.detail} ${error instanceof Error ? error.message : ''}`.trim(), - ariaLabel: `GitHub ${kind} lookup failed: ${failure.label}`, - }; -} - -function githubLookupFailureDescription(kind: GitHubLookupFailureKind): { - readonly label: string; - readonly detail: string; -} { - switch (kind) { - case 'authenticationRequired': - return { - label: 'Authorization required', - detail: 'Authorize GitHub repository access in VS Code to load this link.', - }; - case 'authenticationFailed': - return { - label: 'Authentication failed', - detail: 'GitHub rejected the current VS Code authentication session.', - }; - case 'accessDenied': - return { - label: 'Access denied', - detail: 'The current GitHub account cannot access this resource.', - }; - case 'rateLimited': - return { - label: 'Rate limited', - detail: 'GitHub API rate limiting prevented this lookup.', - }; - case 'notFound': - return { - label: 'Not found', - detail: 'GitHub did not find this resource, or the current account cannot access it.', - }; - case 'invalidResponse': - return { - label: 'Invalid response', - detail: 'GitHub returned data the Markdown editor could not read.', - }; - case 'requestFailed': - return { - label: 'Lookup failed', - detail: 'GitHub returned an unsuccessful response.', - }; - } -} - -interface GitRefData { - readonly ref: string; -} - -function readGitRefs(value: unknown): readonly GitRefData[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const refs: GitRefData[] = []; - for (const item of value) { - if (!isRecord(item) || typeof item.ref !== 'string') { - return undefined; - } - refs.push({ ref: item.ref }); - } - return refs; -} - -export function resolveGitHubTreePath( - segments: readonly string[], - refs: readonly GitRefData[], -): { readonly branch: string; readonly path?: string } | undefined { - const target = segments.join('/'); - const branch = refs - .map(ref => ref.ref.startsWith('refs/heads/') ? ref.ref.slice('refs/heads/'.length) : undefined) - .filter((candidate): candidate is string => !!candidate && (target === candidate || target.startsWith(`${candidate}/`))) - .sort((a, b) => b.length - a.length)[0]; - if (!branch) { - return undefined; - } - const path = target === branch ? undefined : target.slice(branch.length + 1); - return { branch, ...(path ? { path } : {}) }; -} - -interface IssueData { - readonly title: string; - readonly state: 'open' | 'closed'; - readonly stateReason?: 'completed' | 'not_planned' | 'reopened'; -} - -function readIssue(value: unknown): IssueData | undefined { - if (!isRecord(value) || typeof value.title !== 'string' || (value.state !== 'open' && value.state !== 'closed')) { - return undefined; - } - const stateReason = value.state_reason === 'completed' || value.state_reason === 'not_planned' || value.state_reason === 'reopened' - ? value.state_reason - : undefined; - return { title: value.title, state: value.state, ...(stateReason ? { stateReason } : {}) }; -} - -interface PullRequestData extends IssueData { - readonly draft: boolean; - readonly merged: boolean; - readonly headSha: string; -} - -function readPullRequest(value: unknown): PullRequestData | undefined { - if (!isRecord(value) || !isRecord(value.head)) { - return undefined; - } - const issue = readIssue(value); - return issue - && typeof value.draft === 'boolean' - && typeof value.merged === 'boolean' - && typeof value.head.sha === 'string' - ? { ...issue, draft: value.draft, merged: value.merged, headSha: value.head.sha } - : undefined; -} - -interface CheckRunData { - readonly status: string; - readonly conclusion: string | null; -} - -function readCheckRuns(value: unknown): readonly CheckRunData[] | undefined { - if (!isRecord(value) || !Array.isArray(value.check_runs)) { - return undefined; - } - const runs: CheckRunData[] = []; - for (const run of value.check_runs) { - if (!isRecord(run) || typeof run.status !== 'string' || (run.conclusion !== null && typeof run.conclusion !== 'string')) { - return undefined; - } - runs.push({ status: run.status, conclusion: run.conclusion }); - } - return runs; -} - -export function getGitHubIssueStatus( - state: IssueData['state'], - stateReason: IssueData['stateReason'], -): LinkPresentationStatus { - if (state === 'open') { - return { kind: 'open', label: 'Open' }; - } - return stateReason === 'not_planned' - ? { kind: 'notPlanned', label: 'Not planned' } - : { kind: 'closed', label: 'Closed' }; -} - -export function getGitHubPullRequestStatus( - state: PullRequestData['state'], - draft: boolean, - merged: boolean, -): LinkPresentationStatus { - if (merged) { - return { kind: 'merged', label: 'Merged' }; - } - if (draft) { - return { kind: 'draft', label: 'Draft' }; - } - return state === 'closed' - ? { kind: 'closed', label: 'Closed' } - : { kind: 'open', label: 'Open' }; -} - -export function shouldShowGitHubPullRequestChecks(status: LinkPresentationStatus): boolean { - return status.kind === 'open' || status.kind === 'draft'; -} - -function checkRunStatus(checks: readonly CheckRunData[] | undefined): LinkPresentationStatus | undefined { - if (!checks?.length) { - return undefined; - } - if (checks.some(check => check.status !== 'completed')) { - return { kind: 'pending', label: 'Checks running' }; - } - if (checks.some(check => check.conclusion === 'failure' - || check.conclusion === 'timed_out' - || check.conclusion === 'cancelled' - || check.conclusion === 'action_required')) { - return { kind: 'error', label: 'Checks failed' }; - } - return { kind: 'success', label: 'Checks passed' }; -} - -interface RepositoryData { - readonly language?: string; - readonly stars?: number; -} - -function readRepository(value: unknown): RepositoryData | undefined { - if (!isRecord(value)) { - return undefined; - } - return { - ...(typeof value.language === 'string' ? { language: value.language } : {}), - ...(typeof value.stargazers_count === 'number' ? { stars: value.stargazers_count } : {}), - }; -} - -interface BranchData { - readonly sha: string; -} - -function readBranch(value: unknown): BranchData | undefined { - return isRecord(value) - && isRecord(value.commit) - && typeof value.commit.sha === 'string' - ? { sha: value.commit.sha } - : undefined; -} - -function formatCount(value: number): string { - return value >= 1000 ? `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k` : String(value); -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} diff --git a/extensions/markdown-language-features/src/preview/linkPresentation/linkPresentationBuilders.ts b/extensions/markdown-language-features/src/preview/linkPresentation/linkPresentationBuilders.ts new file mode 100644 index 00000000000..7538dd4b237 --- /dev/null +++ b/extensions/markdown-language-features/src/preview/linkPresentation/linkPresentationBuilders.ts @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export type LinkPresentationKind = + | 'resource' + | 'issue' + | 'pullRequest' + | 'commit' + | 'file' + | 'folder' + | 'session' + | 'repository' + | 'branch'; + +export type LinkPresentationStatusKind = + | 'neutral' + | 'pending' + | 'success' + | 'warning' + | 'error' + | 'open' + | 'closed' + | 'merged' + | 'draft' + | 'notPlanned'; + +export interface LinkPresentationStatus { + readonly kind: LinkPresentationStatusKind; + readonly label: string; +} + +export interface LinkPresentation { + readonly kind: LinkPresentationKind; + readonly title?: string; + readonly detail?: string; + readonly reference?: string; + readonly status?: LinkPresentationStatus; + readonly secondaryStatus?: LinkPresentationStatus; + readonly tooltip?: string; + readonly ariaLabel?: string; + readonly isLoading?: boolean; +} + +export interface GitCommitPresentationData { + readonly hash: string; + readonly message: string; + readonly shortStat?: { + readonly insertions: number; + readonly deletions: number; + }; +} + +export function buildGitCommitPresentation(commit: GitCommitPresentationData): LinkPresentation { + const title = commit.message.split(/\r?\n/, 1)[0]; + const insertions = commit.shortStat?.insertions ?? 0; + const deletions = commit.shortStat?.deletions ?? 0; + const shortHash = commit.hash.slice(0, 7); + return { + kind: 'commit', + detail: title, + tooltip: `${shortHash} · ${title} · ${insertions} insertions, ${deletions} deletions`, + ariaLabel: `Commit ${shortHash}, ${insertions} insertions and ${deletions} deletions: ${title}`, + }; +} + +export function buildGitCommitLookupFailurePresentation(shortHash: string, tooltip: string): LinkPresentation { + return { + kind: 'commit', + status: { kind: 'error', label: 'Not available' }, + tooltip, + ariaLabel: `Git commit ${shortHash} could not be resolved`, + }; +} + +export interface WorkspaceRepositoryPresentationData { + readonly label: string; + readonly href: string; + readonly branch?: string; + readonly changeCount: number; +} + +export function buildWorkspaceRepositoryPresentation(data: WorkspaceRepositoryPresentationData): LinkPresentation { + const detail = [data.branch, data.changeCount ? `${data.changeCount} changes` : 'clean'].filter((value): value is string => !!value).join(' · '); + return { + kind: 'repository', + ...(detail ? { detail } : {}), + status: data.branch ? { kind: data.changeCount ? 'warning' : 'success', label: data.branch } : undefined, + tooltip: data.href, + ariaLabel: `Local repository ${data.label}${data.branch ? ` on branch ${data.branch}` : ''}, ${data.changeCount ? `${data.changeCount} changes` : 'clean'}`, + }; +} + +export interface WorkspaceResourcePresentationData { + readonly kind: 'file' | 'folder'; + readonly label: string; + readonly href: string; + readonly branch?: string; + readonly modified: boolean; +} + +export function buildWorkspaceResourcePresentation(data: WorkspaceResourcePresentationData): LinkPresentation { + const details = [ + compactParent(data.label), + data.branch, + data.modified ? 'modified' : undefined, + ].filter((value): value is string => !!value); + return { + kind: data.kind, + ...(details.length ? { detail: details.join(' · ') } : {}), + tooltip: data.href, + ariaLabel: `${data.kind === 'folder' ? 'Folder' : 'File'} ${data.label}`, + }; +} + +export function buildLoadingLinkPresentation(kind: LinkPresentation['kind'], label = 'Loading'): LinkPresentation { + return { + kind, + status: { kind: 'pending', label }, + }; +} + +export function buildWorkspaceLookupFailurePresentation( + kind: 'file' | 'folder', + label: string, + tooltip: string, + ariaLabel: string, +): LinkPresentation { + return { + kind, + status: { kind: 'error', label }, + tooltip, + ariaLabel, + }; +} + +function relativeParent(value: string): string | undefined { + const separator = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')); + return separator > 0 ? value.slice(0, separator) : undefined; +} + +function compactParent(value: string): string | undefined { + const parent = relativeParent(value); + if (!parent) { + return undefined; + } + if (!/^(?:[a-z]:[\\/]|[\\/])/i.test(parent)) { + return parent; + } + return parent.split(/[\\/]+/).filter(Boolean).slice(-4).join('/'); +} diff --git a/extensions/markdown-language-features/src/preview/linkPresentation/linkPresentationResolver.ts b/extensions/markdown-language-features/src/preview/linkPresentation/linkPresentationResolver.ts index 7b42b653ea3..c099b9cd219 100644 --- a/extensions/markdown-language-features/src/preview/linkPresentation/linkPresentationResolver.ts +++ b/extensions/markdown-language-features/src/preview/linkPresentation/linkPresentationResolver.ts @@ -8,11 +8,6 @@ import { derived, observableValue, type IObservable, type ISettableObservable } import * as vscode from 'vscode'; import type { ILogger } from '../../logging'; -const cacheLifetimeMs = 60_000; -const persistentCacheLifetimeMs = 7 * 24 * 60 * 60 * 1_000; -const persistentCacheEntryLimit = 100; -const persistentCacheKey = 'markdown.linkPresentations.cache.v1'; - export type LinkPresentation = MarkdownLinkPresentation & { readonly isLoading?: boolean; }; @@ -37,118 +32,6 @@ export function decodeUrlPathSegments(uri: URL): string[] | undefined { } } -interface LinkPresentationCacheEntry { - readonly value: Promise; - readonly expiresAt: number; -} - -interface PersistedLinkPresentationCacheEntry { - readonly href: string; - readonly presentation: LinkPresentation; - readonly storedAt: number; -} - -export class LinkPresentationCache { - readonly #entries = new Map(); - readonly #persistentEntries = new Map(); - readonly #storage: vscode.Memento | undefined; - readonly #logger: ILogger | undefined; - #writeQueue = Promise.resolve(); - #generation = 0; - - constructor(storage?: vscode.Memento, logger?: ILogger) { - this.#storage = storage; - this.#logger = logger; - for (const entry of readPersistentLinkPresentationCache(storage?.get(persistentCacheKey))) { - this.#persistentEntries.set(entry.href, entry); - } - } - - getPersisted(href: string, now = Date.now()): LinkPresentation | undefined { - const entry = this.#persistentEntries.get(href); - if (!entry) { - return undefined; - } - if (entry.storedAt + persistentCacheLifetimeMs <= now) { - this.#persistentEntries.delete(href); - this.#persist(); - return undefined; - } - return { ...entry.presentation, isLoading: true }; - } - - get( - href: string, - resolve: () => Promise, - now = Date.now(), - ): Promise { - this.#removeExpiredMemoryEntries(now); - const cached = this.#entries.get(href); - if (cached) { - return cached.value; - } - - const value = resolve(); - const entry = { value, expiresAt: now + cacheLifetimeMs }; - const generation = this.#generation; - this.#entries.set(href, entry); - void value.then(presentation => { - if (generation !== this.#generation) { - return; - } - this.#persistentEntries.set(href, { - href, - presentation: { ...presentation, isLoading: undefined }, - storedAt: Date.now(), - }); - this.#trimPersistentEntries(); - this.#persist(); - }, () => { - if (this.#entries.get(href) === entry) { - this.#entries.delete(href); - } - }); - return value; - } - - clear(): void { - this.#generation++; - this.#entries.clear(); - this.#persistentEntries.clear(); - this.#persist(); - } - - #removeExpiredMemoryEntries(now: number): void { - for (const [key, entry] of this.#entries) { - if (entry.expiresAt <= now) { - this.#entries.delete(key); - } - } - } - - #trimPersistentEntries(): void { - const entries = [...this.#persistentEntries.values()].sort((a, b) => b.storedAt - a.storedAt); - this.#persistentEntries.clear(); - for (const entry of entries.slice(0, persistentCacheEntryLimit)) { - this.#persistentEntries.set(entry.href, entry); - } - } - - #persist(): void { - const storage = this.#storage; - if (!storage) { - return; - } - const value = { - version: 1, - entries: [...this.#persistentEntries.values()], - }; - this.#writeQueue = this.#writeQueue.then(() => storage.update(persistentCacheKey, value)).then(undefined, error => { - this.#logger?.trace('Markdown rich link', 'Failed to persist link presentation cache', error); - }); - } -} - export class ImmutableLinkPresentationCache { readonly #entries = new Map>(); @@ -243,72 +126,3 @@ class AsyncLinkPresentation implements vscode.Disposable { }); } } - -function readPersistentLinkPresentationCache(value: unknown): readonly PersistedLinkPresentationCacheEntry[] { - if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.entries)) { - return []; - } - return value.entries.flatMap(entry => { - if (!isRecord(entry) || typeof entry.href !== 'string' || typeof entry.storedAt !== 'number') { - return []; - } - const presentation = readLinkPresentation(entry.presentation); - return presentation ? [{ href: entry.href, presentation, storedAt: entry.storedAt }] : []; - }); -} - -function readLinkPresentation(value: unknown): LinkPresentation | undefined { - if (!isRecord(value) || !isLinkPresentationKind(value.kind)) { - return undefined; - } - const status = readLinkPresentationStatus(value.status); - const secondaryStatus = readLinkPresentationStatus(value.secondaryStatus); - if ((value.status !== undefined && !status) || (value.secondaryStatus !== undefined && !secondaryStatus)) { - return undefined; - } - return { - kind: value.kind, - ...(typeof value.title === 'string' ? { title: value.title } : {}), - ...(typeof value.detail === 'string' ? { detail: value.detail } : {}), - ...(typeof value.reference === 'string' ? { reference: value.reference } : {}), - ...(status ? { status } : {}), - ...(secondaryStatus ? { secondaryStatus } : {}), - ...(typeof value.tooltip === 'string' ? { tooltip: value.tooltip } : {}), - ...(typeof value.ariaLabel === 'string' ? { ariaLabel: value.ariaLabel } : {}), - }; -} - -function readLinkPresentationStatus(value: unknown): MarkdownLinkPresentation['status'] | undefined { - return isRecord(value) && isLinkPresentationStatusKind(value.kind) && typeof value.label === 'string' - ? { kind: value.kind, label: value.label } - : undefined; -} - -function isLinkPresentationKind(value: unknown): value is LinkPresentation['kind'] { - return value === 'resource' - || value === 'issue' - || value === 'pullRequest' - || value === 'commit' - || value === 'file' - || value === 'folder' - || value === 'session' - || value === 'repository' - || value === 'branch'; -} - -function isLinkPresentationStatusKind(value: unknown): value is NonNullable['kind'] { - return value === 'neutral' - || value === 'pending' - || value === 'success' - || value === 'warning' - || value === 'error' - || value === 'open' - || value === 'closed' - || value === 'merged' - || value === 'draft' - || value === 'notPlanned'; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} diff --git a/extensions/markdown-language-features/src/preview/linkPresentation/linkPresentationService.ts b/extensions/markdown-language-features/src/preview/linkPresentation/linkPresentationService.ts index fdd72829059..e016f09e0f5 100644 --- a/extensions/markdown-language-features/src/preview/linkPresentation/linkPresentationService.ts +++ b/extensions/markdown-language-features/src/preview/linkPresentation/linkPresentationService.ts @@ -7,13 +7,15 @@ import { autorun, type IObservable } from '@vscode/observables'; import * as vscode from 'vscode'; import type { ILogger } from '../../logging'; import { Disposable } from '../../util/dispose'; -import { GitHubLinkPresentationResolver } from './githubLinkPresentationResolver'; import { GitLinkPresentationResolver } from './gitLinkPresentationResolver'; -import { ImmutableLinkPresentationCache, LinkPresentationCache, type LinkPresentation, type LinkPresentationResolver, type LinkPresentationResolverContext } from './linkPresentationResolver'; +import { ImmutableLinkPresentationCache, type LinkPresentation, type LinkPresentationResolver, type LinkPresentationResolverContext } from './linkPresentationResolver'; import { WorkspaceLinkPresentationResolver } from './workspaceLinkPresentationResolver'; const refreshIntervalMs = 30_000; -export const linkPresentationProviderId = 'markdown.linkPresentations'; +export const linkPresentationProviderIds = [ + 'markdown.gitCommitLinkPresentations', + 'markdown.workspaceFileLinkPresentations', +] as const; export interface LinkPresentationWatch extends vscode.Disposable { readonly presentation: IObservable; @@ -138,28 +140,25 @@ export class LinkPresentationService extends Disposable { } } -export function createSharedLinkPresentationService(globalState: vscode.Memento, logger: ILogger): LinkPresentationService { +export function createSharedLinkPresentationService(logger: ILogger): LinkPresentationService { return new LinkPresentationService([ new GitLinkPresentationResolver(new ImmutableLinkPresentationCache()), - new GitHubLinkPresentationResolver(new LinkPresentationCache(globalState, logger)), new WorkspaceLinkPresentationResolver(), ], logger); } export function registerLinkPresentationProvider(service: LinkPresentationService): vscode.Disposable { - return vscode.window.registerLinkPresentationProvider( - linkPresentationProviderId, - { - provideLinkPresentationWatcher: resource => { - const href = resource.toString(true); - const watch = service.watch(href); - if (!watch) { - throw new Error(`No link presentation resolver accepted ${href}.`); - } - return new ExtensionLinkPresentationWatcher(watch); - }, + const provider: vscode.LinkPresentationProvider = { + provideLinkPresentationWatcher: resource => { + const href = resource.toString(true); + const watch = service.watch(href); + if (!watch) { + throw new Error(`No link presentation resolver accepted ${href}.`); + } + return new ExtensionLinkPresentationWatcher(watch); }, - ); + }; + return vscode.Disposable.from(...linkPresentationProviderIds.map(id => vscode.window.registerLinkPresentationProvider(id, provider))); } class ExtensionLinkPresentationWatcher extends Disposable implements vscode.LinkPresentationWatcher { diff --git a/extensions/markdown-language-features/src/preview/linkPresentation/workspaceLinkPresentationResolver.ts b/extensions/markdown-language-features/src/preview/linkPresentation/workspaceLinkPresentationResolver.ts index 2c4e64e8c64..e7252e0da98 100644 --- a/extensions/markdown-language-features/src/preview/linkPresentation/workspaceLinkPresentationResolver.ts +++ b/extensions/markdown-language-features/src/preview/linkPresentation/workspaceLinkPresentationResolver.ts @@ -6,6 +6,7 @@ import type { LinkPresentation } from '@vscode/markdown-editor'; import type { IObservable } from '@vscode/observables'; import * as vscode from 'vscode'; +import { buildLoadingLinkPresentation, buildWorkspaceLookupFailurePresentation, buildWorkspaceResourcePresentation } from './linkPresentationBuilders'; import { createAsyncLinkPresentation, type LinkPresentationResolver, type LinkPresentationResolverContext } from './linkPresentationResolver'; export class WorkspaceLinkPresentationResolver implements LinkPresentationResolver { @@ -30,18 +31,15 @@ export class WorkspaceLinkPresentationResolver implements LinkPresentationResolv } return createAsyncLinkPresentation( href, - { - kind: 'file', - status: { kind: 'pending', label: vscode.l10n.t("Loading") }, - }, + buildLoadingLinkPresentation('file', vscode.l10n.t("Loading")), context, () => this.#resolve(href), - error => ({ - kind: 'file', - status: { kind: 'error', label: vscode.l10n.t("Not found") }, - tooltip: error instanceof Error ? error.message : vscode.l10n.t("The workspace resource could not be resolved."), - ariaLabel: vscode.l10n.t("Workspace resource could not be resolved: {0}", href), - }), + error => buildWorkspaceLookupFailurePresentation( + 'file', + vscode.l10n.t("Not found"), + error instanceof Error ? error.message : vscode.l10n.t("The workspace resource could not be resolved."), + vscode.l10n.t("Workspace resource could not be resolved: {0}", href), + ), [context.onDidRequestRefresh, this.#onDidChangeWorkspaceResource.event], ); } @@ -53,38 +51,17 @@ export class WorkspaceLinkPresentationResolver implements LinkPresentationResolv async #resolve(href: string): Promise { const uri = vscode.Uri.parse(href); - const stat = await vscode.workspace.fs.stat(uri); - return this.#present(uri, stat.type === vscode.FileType.Directory ? 'folder' : 'file'); - } - - async #present(uri: vscode.Uri, kind: 'file' | 'folder'): Promise { + await vscode.workspace.fs.stat(uri); const label = vscode.workspace.asRelativePath(uri, false); const repository = await this.#getGitApi().then(api => api?.getRepository(uri) ?? undefined); const branch = repository?.state.HEAD?.name; - const changed = repository ? repositoryChangeCount(repository) : 0; - const isRepositoryRoot = kind === 'folder' && repository?.rootUri.fsPath === uri.fsPath; - if (isRepositoryRoot) { - const detail = [branch, changed ? `${changed} changes` : 'clean'].filter((value): value is string => !!value).join(' · '); - return { - kind: 'repository', - ...(detail ? { detail } : {}), - status: branch ? { kind: changed ? 'warning' : 'success', label: branch } : undefined, - tooltip: uri.toString(true), - ariaLabel: `Local repository ${label}${branch ? ` on branch ${branch}` : ''}, ${changed ? `${changed} changes` : 'clean'}`, - }; - } - - const details = [ - compactParent(label), + return buildWorkspaceResourcePresentation({ + kind: 'file', + label, + href: uri.toString(true), branch, - repository && repositoryContainsChange(repository, uri) ? 'modified' : undefined, - ].filter((value): value is string => !!value); - return { - kind, - ...(details.length ? { detail: details.join(' · ') } : {}), - tooltip: uri.toString(true), - ariaLabel: `${kind === 'folder' ? 'Folder' : 'File'} ${label}`, - }; + modified: !!repository && repositoryContainsChange(repository, uri), + }); } #getGitApi(): Promise { @@ -125,14 +102,6 @@ interface GitChange { readonly uri: vscode.Uri; } -function repositoryChangeCount(repository: GitRepository): number { - const state = repository.state; - return state.mergeChanges.length - + state.indexChanges.length - + state.workingTreeChanges.length - + state.untrackedChanges.length; -} - function repositoryContainsChange(repository: GitRepository, uri: vscode.Uri): boolean { const key = uri.toString(); const state = repository.state; @@ -143,19 +112,3 @@ function repositoryContainsChange(repository: GitRepository, uri: vscode.Uri): b ...state.untrackedChanges, ].some(change => change.uri.toString() === key); } - -function relativeParent(value: string): string | undefined { - const separator = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')); - return separator > 0 ? value.slice(0, separator) : undefined; -} - -function compactParent(value: string): string | undefined { - const parent = relativeParent(value); - if (!parent) { - return undefined; - } - if (!/^(?:[a-z]:[\\/]|[\\/])/i.test(parent)) { - return parent; - } - return parent.split(/[\\/]+/).filter(Boolean).slice(-4).join('/'); -} diff --git a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts index b3ae2f31be9..ff74f091048 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts @@ -761,12 +761,12 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT content: document.getText(), documentVersion: document.version, readonly: this.#globalState.get(MarkdownEditorProvider.#readonlyStateKey, true), - richLinksEnabled: vscode.workspace.getConfiguration('markdown').get('experimental.richLinks.enabled', false), + richLinksEnabled: vscode.workspace.getConfiguration('markdown').get('experimental.richLinks.enabled', true), linkPresentationRules: vscode.window.linkPresentationRules.map(rule => ({ id: rule.id, source: rule.uriPattern.source, flags: rule.uriPattern.flags, - initialKind: rule.initialKind === 'chat' ? 'session' : rule.initialKind, + kind: rule.kind === 'chat' ? 'session' : rule.kind, })), }); diff --git a/extensions/markdown-language-features/src/preview/webviewInitialState.ts b/extensions/markdown-language-features/src/preview/webviewInitialState.ts index b9e5474cb21..16452fd8534 100644 --- a/extensions/markdown-language-features/src/preview/webviewInitialState.ts +++ b/extensions/markdown-language-features/src/preview/webviewInitialState.ts @@ -8,7 +8,7 @@ export interface MarkdownEditorInitialState { readonly documentVersion: number; readonly readonly: boolean; readonly richLinksEnabled: boolean; - readonly linkPresentationRules: readonly { id: string; source: string; flags: string; initialKind: string }[]; + readonly linkPresentationRules: readonly { id: string; source: string; flags: string; kind: string }[]; } /** diff --git a/extensions/markdown-language-features/src/test/markdownEditorRichLinks.test.ts b/extensions/markdown-language-features/src/test/markdownEditorRichLinks.test.ts index bfe6db9d73e..0da3f7f4c93 100644 --- a/extensions/markdown-language-features/src/test/markdownEditorRichLinks.test.ts +++ b/extensions/markdown-language-features/src/test/markdownEditorRichLinks.test.ts @@ -7,112 +7,11 @@ import * as assert from 'assert'; import { autorun, derived, observableValue } from '@vscode/observables'; import 'mocha'; import * as vscode from 'vscode'; -import { - getGitHubIssueStatus, - getGitHubLookupFailurePresentation, - getGitHubPullRequestStatus, - GitHubLookupError, - resolveGitHubTreePath, - shouldShowGitHubPullRequestChecks, -} from '../preview/linkPresentation/githubLinkPresentationResolver'; import { getGitCommitPresentation, GitLinkPresentationResolver, normalizeGitRemoteUrl } from '../preview/linkPresentation/gitLinkPresentationResolver'; -import { createAsyncLinkPresentation, ImmutableLinkPresentationCache, LinkPresentationCache } from '../preview/linkPresentation/linkPresentationResolver'; +import { createAsyncLinkPresentation, ImmutableLinkPresentationCache } from '../preview/linkPresentation/linkPresentationResolver'; import { LinkPresentationService } from '../preview/linkPresentation/linkPresentationService'; -class TestMemento implements vscode.Memento { - readonly #values = new Map(); - - keys(): readonly string[] { - return [...this.#values.keys()]; - } - - get(key: string): T | undefined; - get(key: string, defaultValue: T): T; - get(key: string, defaultValue?: T): T | undefined { - return (this.#values.get(key) as T | undefined) ?? defaultValue; - } - - update(key: string, value: unknown): Thenable { - if (value === undefined) { - this.#values.delete(key); - } else { - this.#values.set(key, value); - } - return Promise.resolve(); - } -} - suite('Markdown editor rich links', () => { - test('separates GitHub branch names from folder paths', () => { - const refs = [ - { ref: 'refs/heads/main' }, - { ref: 'refs/heads/feature/rich-links' }, - ]; - - assert.deepStrictEqual(resolveGitHubTreePath(['main'], refs), { - branch: 'main', - }); - assert.deepStrictEqual(resolveGitHubTreePath(['main', 'src', 'vs'], refs), { - branch: 'main', - path: 'src/vs', - }); - assert.deepStrictEqual(resolveGitHubTreePath(['feature', 'rich-links'], refs), { - branch: 'feature/rich-links', - }); - assert.deepStrictEqual(resolveGitHubTreePath(['feature', 'rich-links', 'src'], refs), { - branch: 'feature/rich-links', - path: 'src', - }); - }); - - test('maps GitHub issue lifecycle states', () => { - assert.deepStrictEqual(getGitHubIssueStatus('open', undefined), { kind: 'open', label: 'Open' }); - assert.deepStrictEqual(getGitHubIssueStatus('closed', 'completed'), { kind: 'closed', label: 'Closed' }); - assert.deepStrictEqual(getGitHubIssueStatus('closed', 'not_planned'), { kind: 'notPlanned', label: 'Not planned' }); - }); - - test('maps GitHub pull request lifecycle states', () => { - const open = getGitHubPullRequestStatus('open', false, false); - const draft = getGitHubPullRequestStatus('open', true, false); - const closed = getGitHubPullRequestStatus('closed', false, false); - const merged = getGitHubPullRequestStatus('closed', false, true); - - assert.deepStrictEqual(open, { kind: 'open', label: 'Open' }); - assert.deepStrictEqual(draft, { kind: 'draft', label: 'Draft' }); - assert.deepStrictEqual(closed, { kind: 'closed', label: 'Closed' }); - assert.deepStrictEqual(merged, { kind: 'merged', label: 'Merged' }); - assert.strictEqual(shouldShowGitHubPullRequestChecks(open), true); - assert.strictEqual(shouldShowGitHubPullRequestChecks(draft), true); - assert.strictEqual(shouldShowGitHubPullRequestChecks(closed), false); - assert.strictEqual(shouldShowGitHubPullRequestChecks(merged), false); - }); - - test('keeps GitHub lookup failures visible and actionable', () => { - assert.deepStrictEqual( - getGitHubLookupFailurePresentation( - 'https://github.com/hediet/demo-json-schema-validator/pull/5', - new GitHubLookupError('authenticationRequired', 'No GitHub session.'), - ), - { - kind: 'pullRequest', - status: { kind: 'error', label: 'Authorization required' }, - tooltip: 'Authorize GitHub repository access in VS Code to load this link. No GitHub session.', - ariaLabel: 'GitHub pullRequest lookup failed: Authorization required', - }, - ); - assert.deepStrictEqual( - getGitHubLookupFailurePresentation( - 'https://github.com/hediet/demo-json-schema-validator/issues/1', - new GitHubLookupError('rateLimited', '403 Forbidden'), - )?.status, - { kind: 'error', label: 'Rate limited' }, - ); - assert.strictEqual( - getGitHubLookupFailurePresentation('https://example.com/issues/1', new Error('offline')), - undefined, - ); - }); - test('normalizes common Git remote URL formats', () => { assert.deepStrictEqual([ normalizeGitRemoteUrl('https://github.com/microsoft/vscode.git'), @@ -156,13 +55,6 @@ suite('Markdown editor rich links', () => { } }); - test('ignores malformed GitHub paths', () => { - assert.strictEqual( - getGitHubLookupFailurePresentation('https://github.com/microsoft/vscode/issues/%', new Error('failed')), - undefined, - ); - }); - test('shows Git commit metadata', () => { assert.deepStrictEqual(getGitCommitPresentation({ hash: '1234567890abcdef', @@ -225,8 +117,8 @@ suite('Markdown editor rich links', () => { }; const service = new LinkPresentationService([resolver], { trace: () => { } }); - const first = service.watch('https://github.com/microsoft/vscode/pull/1')!; - const second = service.watch('https://github.com/microsoft/vscode/pull/1')!; + const first = service.watch('https://example.com/pull/1')!; + const second = service.watch('https://example.com/pull/1')!; assert.deepStrictEqual({ resolveCount, activeSubscriptions }, { resolveCount: 1, activeSubscriptions: 1 }); first.dispose(); @@ -234,7 +126,7 @@ suite('Markdown editor rich links', () => { second.dispose(); assert.strictEqual(activeSubscriptions, 0); - const third = service.watch('https://github.com/microsoft/vscode/pull/1')!; + const third = service.watch('https://example.com/pull/1')!; assert.deepStrictEqual({ resolveCount, activeSubscriptions }, { resolveCount: 2, activeSubscriptions: 1 }); third.dispose(); service.dispose(); @@ -286,65 +178,12 @@ suite('Markdown editor rich links', () => { }); }); - test('expires mutable link presentations after one minute', async () => { - const cache = new LinkPresentationCache(); - let resolveCount = 0; - const resolve = async () => ({ kind: 'issue' as const, title: String(++resolveCount) }); - - const first = await cache.get('https://github.com/microsoft/vscode/issues/1', resolve, 0); - const cached = await cache.get('https://github.com/microsoft/vscode/issues/1', resolve, 59_999); - const refreshed = await cache.get('https://github.com/microsoft/vscode/issues/1', resolve, 60_000); - - assert.deepStrictEqual({ first, cached, refreshed, resolveCount }, { - first: { kind: 'issue', title: '1' }, - cached: { kind: 'issue', title: '1' }, - refreshed: { kind: 'issue', title: '2' }, - resolveCount: 2, - }); - }); - - test('restores persistent presentations as loading and refreshes them', async () => { - const storage = new TestMemento(); - const href = 'https://github.com/microsoft/vscode/issues/1'; - let resolveCount = 0; - const resolve = async () => ({ kind: 'issue' as const, title: String(++resolveCount) }); - const firstCache = new LinkPresentationCache(storage); - await firstCache.get(href, resolve); - await Promise.resolve(); - - const restoredCache = new LinkPresentationCache(storage); - const loading = restoredCache.getPersisted(href); - const refreshed = await restoredCache.get(href, resolve); - - assert.deepStrictEqual({ loading, refreshed, resolveCount }, { - loading: { kind: 'issue', title: '1', isLoading: true }, - refreshed: { kind: 'issue', title: '2' }, - resolveCount: 2, - }); - }); - - test('does not restore a request that completes after the cache is cleared', async () => { - const storage = new TestMemento(); - const cache = new LinkPresentationCache(storage); - const href = 'https://github.com/microsoft/vscode/issues/1'; - let completeRequest!: (value: { kind: 'issue'; title: string }) => void; - const request = new Promise<{ kind: 'issue'; title: string }>(resolve => completeRequest = resolve); - - const pending = cache.get(href, () => request); - cache.clear(); - completeRequest({ kind: 'issue', title: 'Old issue' }); - await pending; - await Promise.resolve(); - - assert.strictEqual(new LinkPresentationCache(storage).getPersisted(href), undefined); - }); - test('keeps a restored presentation visible while loading in the background', async () => { const requestRefresh = new vscode.EventEmitter(); let completeRefresh!: (value: { kind: 'issue'; title: string }) => void; const refresh = new Promise<{ kind: 'issue'; title: string }>(resolve => completeRefresh = resolve); const presentation = createAsyncLinkPresentation( - 'https://github.com/microsoft/vscode/issues/1', + 'https://example.com/issues/1', { kind: 'issue', title: 'Cached issue', isLoading: true }, { onDidRequestRefresh: requestRefresh.event, @@ -373,7 +212,7 @@ suite('Markdown editor rich links', () => { let resolveCount = 0; let completeRefresh!: (value: { kind: 'issue'; title: string }) => void; const presentation = createAsyncLinkPresentation( - 'https://github.com/microsoft/vscode/issues/1', + 'https://example.com/issues/1', { kind: 'issue', status: { kind: 'pending', label: 'Loading' } }, { onDidRequestRefresh: requestRefresh.event, diff --git a/src/vs/platform/agentHost/common/openSessionLink.ts b/src/vs/platform/agentHost/common/openSessionLink.ts index eaf9f8002af..449d08457e3 100644 --- a/src/vs/platform/agentHost/common/openSessionLink.ts +++ b/src/vs/platform/agentHost/common/openSessionLink.ts @@ -22,10 +22,12 @@ import { DEFAULT_CHAT_ID, isAhpChatChannel, parseChatUri } from './state/session */ export const AGENT_HOST_SESSION_LINK_SCHEME = 'agent-host-session'; export const AGENT_HOST_SESSION_LINK_PATTERN = /^agent-host-session:\/\/[^/?#]+\/[^?#]+(?:\?[^#]*)?(?:#.*)?$/i; +export const AGENT_HOST_SESSION_ONLY_LINK_PATTERN = /^(?![^#]*[?&]chat=)agent-host-session:\/\/[^/?#]+\/[^?#]+(?:\?[^#]*)?(?:#.*)?$/i; +export const AGENT_HOST_CHAT_LINK_PATTERN = /^(?=[^#]*[?&]chat=)agent-host-session:\/\/[^/?#]+\/[^?#]+(?:\?[^#]*)?(?:#.*)?$/i; export type AgentSessionLinkStatus = 'untitled' | 'inProgress' | 'needsInput' | 'completed' | 'error'; -export function createAgentSessionLinkPresentation(title: string, description: string | undefined, status: AgentSessionLinkStatus, kind: 'session' | 'chat' = 'session'): ILinkPresentation { +export function buildAgentSessionLinkPresentation(title: string, description: string | undefined, status: AgentSessionLinkStatus, kind: 'session' | 'chat' = 'session'): ILinkPresentation { const presentationStatus = getAgentSessionLinkPresentationStatus(status); return { kind, diff --git a/src/vs/platform/agentHost/test/common/openSessionLink.test.ts b/src/vs/platform/agentHost/test/common/openSessionLink.test.ts index 18a792b5ca8..c6952a30cfa 100644 --- a/src/vs/platform/agentHost/test/common/openSessionLink.test.ts +++ b/src/vs/platform/agentHost/test/common/openSessionLink.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { buildOpenSessionLinkForChatResource, buildOpenSessionLinkUri, createAgentSessionLinkPresentation, isCreateChatTool, isCreateSessionTool, isSendMessageTool, parseOpenSessionLinkChatId, parseOpenSessionLinkTurnId, parseOpenSessionLinkUri } from '../../common/openSessionLink.js'; +import { AGENT_HOST_CHAT_LINK_PATTERN, AGENT_HOST_SESSION_ONLY_LINK_PATTERN, buildAgentSessionLinkPresentation, buildOpenSessionLinkForChatResource, buildOpenSessionLinkUri, isCreateChatTool, isCreateSessionTool, isSendMessageTool, parseOpenSessionLinkChatId, parseOpenSessionLinkTurnId, parseOpenSessionLinkUri } from '../../common/openSessionLink.js'; import { buildChatUri, buildDefaultChatUri } from '../../common/state/sessionState.js'; suite('openSessionLink', () => { @@ -66,6 +66,20 @@ suite('openSessionLink', () => { assert.strictEqual(parseOpenSessionLinkChatId('agent-host-session://copilotcli/abc-123?chat=%ZZ'), undefined); }); + test('classifies session and chat links with stable kinds', () => { + assert.deepStrictEqual({ + session: AGENT_HOST_SESSION_ONLY_LINK_PATTERN.test('agent-host-session://copilotcli/abc-123'), + sessionAsChat: AGENT_HOST_CHAT_LINK_PATTERN.test('agent-host-session://copilotcli/abc-123'), + chatAsSession: AGENT_HOST_SESSION_ONLY_LINK_PATTERN.test('agent-host-session://copilotcli/abc-123?chat=peer1'), + chat: AGENT_HOST_CHAT_LINK_PATTERN.test('agent-host-session://copilotcli/abc-123?chat=peer1'), + }, { + session: true, + sessionAsChat: false, + chatAsSession: false, + chat: true, + }); + }); + test('buildOpenSessionLinkForChatResource maps chat resources to session links', () => { const session = 'copilotcli:/abc-123'; assert.deepStrictEqual({ @@ -87,8 +101,8 @@ suite('openSessionLink', () => { test('creates generic link presentations for agent sessions', () => { assert.deepStrictEqual({ - session: createAgentSessionLinkPresentation('Implement rich links', 'Updating core', 'needsInput'), - chat: createAgentSessionLinkPresentation('Investigate tests', 'Updating core', 'completed', 'chat'), + session: buildAgentSessionLinkPresentation('Implement rich links', 'Updating core', 'needsInput'), + chat: buildAgentSessionLinkPresentation('Investigate tests', 'Updating core', 'completed', 'chat'), }, { session: { kind: 'session', diff --git a/src/vs/platform/dataChannel/common/dataChannel.ts b/src/vs/platform/dataChannel/common/dataChannel.ts index 624ce7ef81b..e5a34ae60a7 100644 --- a/src/vs/platform/dataChannel/common/dataChannel.ts +++ b/src/vs/platform/dataChannel/common/dataChannel.ts @@ -157,14 +157,14 @@ export interface ILinkPresentationProvider { export interface ILinkPresentationProviderRegistration { readonly id: string; readonly uriPattern: RegExp; - readonly initialKind: LinkPresentationKind; + readonly kind: LinkPresentationKind; readonly enablement?: string; } export interface ILinkPresentationRule { readonly id: string; readonly uriPattern: RegExp; - readonly initialKind: LinkPresentationKind; + readonly kind: LinkPresentationKind; } export interface ILinkPresentationService { diff --git a/src/vs/platform/github/common/githubQueryService.ts b/src/vs/platform/github/common/githubQueryService.ts index 1fb735c5b19..e5874eba3d5 100644 --- a/src/vs/platform/github/common/githubQueryService.ts +++ b/src/vs/platform/github/common/githubQueryService.ts @@ -18,11 +18,17 @@ export interface GitHubIssueRef extends GitHubRepositoryRef { readonly number: number; } +export type GitHubHydratableResourceRef = + | { readonly kind: 'repository'; readonly ref: GitHubRepositoryRef } + | { readonly kind: 'issue'; readonly ref: GitHubIssueRef }; + export interface GitHubRepository { readonly id?: string; readonly owner: GitHubActor; readonly name: string; readonly nameWithOwner: string; + readonly language?: string; + readonly stars?: number; readonly defaultBranch: string; readonly private: boolean; readonly description: string; @@ -192,6 +198,7 @@ export interface GitHubPullRequestLookup { export interface GitHubQueryApi { subscribeRepository(ref: GitHubRepositoryRef, options: GitHubResourceSubscriptionOptions): GitHubRepositorySubscription; subscribeIssue(ref: GitHubIssueRef, options: GitHubResourceSubscriptionOptions): GitHubIssueSubscription; + hydrateResources(refs: readonly GitHubHydratableResourceRef[], signal: AbortSignal): Promise; compare(ref: GitHubRepositoryRef, base: string, head: string, signal: AbortSignal): Promise; listPullRequests(ref: GitHubRepositoryRef, cursor: string | undefined, signal: AbortSignal): Promise; listPullRequestsWaitingForReview(ref: GitHubRepositoryRef, signal: AbortSignal): Promise; diff --git a/src/vs/platform/github/common/githubQueryServiceImpl.ts b/src/vs/platform/github/common/githubQueryServiceImpl.ts index c74dd9971dc..8056a9598c1 100644 --- a/src/vs/platform/github/common/githubQueryServiceImpl.ts +++ b/src/vs/platform/github/common/githubQueryServiceImpl.ts @@ -13,6 +13,7 @@ import { GitHubChangedFile, GitHubComparison, GitHubComparisonCommit, + GitHubHydratableResourceRef, GitHubIssue, GitHubIssueRef, GitHubIssueResource, @@ -65,6 +66,36 @@ const defaultPollingPolicy: GitHubEntityPollingPolicy = { }; const maximumPaginationPages = 100; +const maximumHydrationBatchSize = 25; +const repositoryHydrationFields = ` + id + owner { id login } + name + nameWithOwner + primaryLanguage { name } + stargazerCount + defaultBranchRef { name } + isPrivate + description + url + isArchived + isFork +`; +const issueHydrationFields = ` + id + number + title + body + url + state + stateReason + author { id login } + assignees(first: 100) { nodes { id login } } + labels(first: 100) { nodes { name } } + createdAt + updatedAt + closedAt +`; const maximumCommitPullRequests = 100; const maximumIssueLinkageBatchSize = 20; @@ -141,6 +172,7 @@ class EntityEntry { dormantAt: number | undefined; /** Consecutive refresh failures, so repeated trouble is retried further apart. */ failureCount = 0; + generation = 0; disposed = false; constructor( @@ -155,6 +187,25 @@ class EntityEntry { : new IssueResourceImpl(this as EntityEntry); } + setLoading(attemptedAt: string): void { + this.state.set({ + ...this.state.get(), + status: 'loading', + complete: false, + attemptedAt, + error: undefined, + }, undefined); + } + + setError(error: NonNullable['error']>): void { + this.state.set({ + ...this.state.get(), + status: 'error', + complete: false, + error, + }, undefined); + } + ref: TRef; } @@ -258,6 +309,147 @@ export class GitHubQueryService extends Disposable implements IGitHubQuery { return subscription; } + async hydrateResources(refs: readonly GitHubHydratableResourceRef[], signal: AbortSignal): Promise { + for (let index = 0; index < refs.length; index += maximumHydrationBatchSize) { + await this._hydrateResourceBatch(refs.slice(index, index + maximumHydrationBatchSize), signal); + } + } + + private async _hydrateResourceBatch(refs: readonly GitHubHydratableResourceRef[], signal: AbortSignal): Promise { + if (refs.length === 0) { + return; + } + const resources = refs.map(item => ({ + item, + entry: item.kind === 'repository' + ? this._getOrCreateEntity('repository', normalizeRepositoryRef(item.ref)) + : this._getOrCreateEntity('issue', normalizeIssueRef(item.ref)), + })).filter(resource => { + const status = resource.entry.state.get().status; + return status !== 'ready' && status !== 'loading'; + }).map(resource => ({ ...resource, generation: ++resource.entry.generation })); + if (resources.length === 0) { + return; + } + const firstRef = resources[0].item.ref; + if (resources.some(resource => !sameAccount(resource.item.ref, { account: firstRef }))) { + throw new GitHubRequestError('GitHub hydration batch spans multiple accounts', 'validation'); + } + + const attemptedAt = new Date(this._clock.now()).toISOString(); + for (const { entry } of resources) { + this._scheduler.cancel(this._entityTaskKey(entry)); + entry.setLoading(attemptedAt); + } + + const definitions: string[] = []; + const selections: string[] = []; + const variables: Record = {}; + for (let index = 0; index < resources.length; index++) { + const item = resources[index].item; + definitions.push(`$owner${index}: String!`, `$repo${index}: String!`); + variables[`owner${index}`] = item.ref.owner; + variables[`repo${index}`] = item.ref.repo; + if (item.kind === 'repository') { + selections.push(`r${index}: repository(owner: $owner${index}, name: $repo${index}) { ${repositoryHydrationFields} }`); + } else { + definitions.push(`$number${index}: Int!`); + variables[`number${index}`] = item.ref.number; + selections.push(`r${index}: repository(owner: $owner${index}, name: $repo${index}) { issue(number: $number${index}) { ${issueHydrationFields} } }`); + } + } + const query = `query HydrateGitHubResources(${definitions.join(', ')}) { ${selections.join('\n')} rateLimit { limit remaining used resetAt } }`; + let data: object; + try { + data = asObject(await this._graphqlRaw(firstRef, query, variables, signal), 'GitHub hydration response was malformed'); + } catch (error) { + for (const { entry, generation } of resources) { + if (entry.disposed || entry.generation !== generation) { + continue; + } + entry.setError(toFragmentError(error)); + if (entry.subscriptions.size > 0) { + this._scheduleEntity(entry, this._clock.now()); + } else { + this._makeEntityDormant(entry); + } + } + throw error; + } + const observedAt = new Date(this._clock.now()).toISOString(); + let hydratedCount = 0; + + for (let index = 0; index < resources.length; index++) { + const { item, entry, generation } = resources[index]; + if (entry.disposed || entry.generation !== generation) { + continue; + } + const repositoryValue = optionalObjectProperty(data, `r${index}`); + try { + if (item.kind === 'repository') { + if (!repositoryValue) { + this._handleMissingHydrationResult(entry); + continue; + } + const value = toGraphQLRepository(repositoryValue); + const repositoryEntry = this._getOrCreateEntity('repository', normalizeRepositoryRef(item.ref)); + repositoryEntry.state.set({ value, status: 'ready', complete: true, observedAt, attemptedAt: observedAt }, undefined); + this._canonicalizeRepository(repositoryEntry, value); + if (repositoryEntry.subscriptions.size === 0) { + this._makeEntityDormant(repositoryEntry); + } else { + this._scheduleEntity(repositoryEntry, this._clock.now() + this._pollDelay(repositoryEntry)); + } + hydratedCount++; + } else { + const issueValue = repositoryValue ? optionalObjectProperty(repositoryValue, 'issue') : undefined; + if (!issueValue) { + this._handleMissingHydrationResult(entry); + continue; + } + const value = toGraphQLIssue(issueValue); + const issueEntry = this._getOrCreateEntity('issue', normalizeIssueRef(item.ref)); + issueEntry.state.set({ value, status: 'ready', complete: true, observedAt, attemptedAt: observedAt }, undefined); + if (issueEntry.subscriptions.size === 0) { + this._makeEntityDormant(issueEntry); + } else if (this._shouldPollEntity(issueEntry)) { + this._scheduleEntity(issueEntry, this._clock.now() + this._pollDelay(issueEntry)); + } + hydratedCount++; + } + } catch (error) { + this._handleHydrationError(entry, error); + } + } + this._logService.trace(`[GitHubQueryService] Hydrated ${hydratedCount} of ${resources.length} resource(s) in one GraphQL request`); + } + + private _handleHydrationError(entry: EntityEntry, error: unknown): void { + entry.state.set({ + ...entry.state.get(), + status: 'error', + complete: false, + error: toFragmentError(error), + }, undefined); + if (entry.subscriptions.size > 0) { + this._scheduleEntity(entry, this._clock.now()); + } else { + this._makeEntityDormant(entry); + } + } + + private _handleMissingHydrationResult(entry: EntityEntry): void { + entry.state.set({ + ...entry.state.get(), + status: 'error', + complete: false, + error: { kind: 'notFound', message: 'GitHub resource was not found' }, + }, undefined); + if (entry.subscriptions.size === 0) { + this._makeEntityDormant(entry); + } + } + subscribeIssue(ref: GitHubIssueRef, options: GitHubResourceSubscriptionOptions): GitHubIssueSubscription { const normalized = normalizeIssueRef(ref); const entry = this._getOrCreateEntity('issue', normalized); @@ -519,6 +711,7 @@ export class GitHubQueryService extends Disposable implements IGitHubQuery { return; } const controller = new AbortController(); + entry.generation++; const operation: IEntityOperation = { controller, promise: this._runEntityFetch(entry, controller).finally(() => { @@ -540,6 +733,10 @@ export class GitHubQueryService extends Disposable implements IGitHubQuery { this.updateEntitySubscription(entry); return; } + this._makeEntityDormant(entry); + } + + private _makeEntityDormant(entry: EntityEntry): void { entry.dormantAt = this._clock.now(); this._logService.trace(`[GitHubQueryService] ${entry.kind} ${formatEntityRef(entry.ref)} became dormant (entry ${entry.id})`); this._scheduler.cancel(this._entityTaskKey(entry)); @@ -948,11 +1145,15 @@ function toRequestPriority(priority: GitHubResourcePriority): 'interactive' | 'v function toRepository(value: unknown): GitHubRepository { const item = asObject(value, 'GitHub repository response was malformed'); const owner = objectProperty(item, 'owner'); + const language = nullableStringProperty(item, 'language'); + const stars = numberProperty(item, 'stargazers_count'); return { id: idProperty(item, 'node_id') ?? idProperty(item, 'id'), owner: requiredActor(owner), name: requiredString(item, 'name'), nameWithOwner: requiredString(item, 'full_name'), + ...(language !== undefined ? { language } : {}), + ...(stars !== undefined ? { stars } : {}), defaultBranch: requiredString(item, 'default_branch'), private: booleanProperty(item, 'private') ?? false, description: nullableStringProperty(item, 'description') ?? '', @@ -962,11 +1163,57 @@ function toRepository(value: unknown): GitHubRepository { }; } +function toGraphQLRepository(value: object): GitHubRepository { + const owner = objectProperty(value, 'owner'); + const primaryLanguage = optionalObjectProperty(value, 'primaryLanguage'); + const defaultBranch = optionalObjectProperty(value, 'defaultBranchRef'); + const stars = numberProperty(value, 'stargazerCount'); + return { + id: idProperty(value, 'id'), + owner: requiredActor(owner), + name: requiredString(value, 'name'), + nameWithOwner: requiredString(value, 'nameWithOwner'), + ...(primaryLanguage ? { language: requiredString(primaryLanguage, 'name') } : {}), + ...(stars !== undefined ? { stars } : {}), + defaultBranch: defaultBranch ? requiredString(defaultBranch, 'name') : '', + private: booleanProperty(value, 'isPrivate') ?? false, + description: nullableStringProperty(value, 'description') ?? '', + url: requiredString(value, 'url'), + archived: booleanProperty(value, 'isArchived') ?? false, + fork: booleanProperty(value, 'isFork') ?? false, + }; +} + +function toGraphQLIssue(value: object): GitHubIssue { + const author = optionalObjectProperty(value, 'author'); + const assignees = objectProperty(value, 'assignees'); + const labels = objectProperty(value, 'labels'); + const stateReason = nullableStringProperty(value, 'stateReason')?.toLowerCase(); + return { + id: idProperty(value, 'id'), + number: requiredNumber(value, 'number'), + title: requiredString(value, 'title'), + body: nullableStringProperty(value, 'body') ?? '', + url: requiredString(value, 'url'), + state: requiredString(value, 'state') === 'CLOSED' ? 'closed' : 'open', + stateReason: stateReason === 'completed' || stateReason === 'not_planned' || stateReason === 'duplicate' || stateReason === 'reopened' + ? stateReason + : undefined, + author: author ? requiredActor(author) : { login: 'ghost' }, + assignees: arrayProperty(assignees, 'nodes').filter(isObject).map(requiredActor), + labels: arrayProperty(labels, 'nodes').filter(isObject).map(label => requiredString(label, 'name')), + createdAt: requiredString(value, 'createdAt'), + updatedAt: requiredString(value, 'updatedAt'), + closedAt: nullableStringProperty(value, 'closedAt'), + }; +} + function toIssue(value: unknown): GitHubIssue { const item = asObject(value, 'GitHub issue response was malformed'); if (Reflect.has(item, 'pull_request')) { throw new GitHubRequestError('Requested GitHub issue is a pull request', 'validation'); } + const author = optionalObjectProperty(item, 'user'); return { id: idProperty(item, 'node_id') ?? idProperty(item, 'id'), number: requiredNumber(item, 'number'), @@ -975,7 +1222,7 @@ function toIssue(value: unknown): GitHubIssue { url: requiredString(item, 'html_url'), state: stringProperty(item, 'state') === 'closed' ? 'closed' : 'open', stateReason: enumProperty(item, 'state_reason', ['completed', 'not_planned', 'duplicate', 'reopened'], undefined), - author: requiredActor(objectProperty(item, 'user')), + author: author ? requiredActor(author) : { login: 'ghost' }, assignees: arrayProperty(item, 'assignees').filter(isObject).map(requiredActor), labels: arrayProperty(item, 'labels').flatMap(label => { if (typeof label === 'string') { diff --git a/src/vs/platform/github/test/node/githubQueryService.test.ts b/src/vs/platform/github/test/node/githubQueryService.test.ts index d73425b32e1..0510ce9ec08 100644 --- a/src/vs/platform/github/test/node/githubQueryService.test.ts +++ b/src/vs/platform/github/test/node/githubQueryService.test.ts @@ -146,6 +146,152 @@ suite('GitHubQueryService', () => { return { account, ref, clock, credentials, service }; } + function graphQLRepository(): object { + return { + id: 'R1', + owner: { id: 'U1', login: 'octo' }, + name: 'repo', + nameWithOwner: 'octo/repo', + primaryLanguage: { name: 'TypeScript' }, + stargazerCount: 42, + defaultBranchRef: { name: 'main' }, + isPrivate: false, + description: 'Repository', + url: 'https://example.test/octo/repo', + isArchived: false, + isFork: false, + }; + } + + function graphQLIssue(): object { + return { + id: 'I7', + number: 7, + title: 'Issue', + body: 'Body', + url: 'https://example.test/octo/repo/issues/7', + state: 'CLOSED', + stateReason: 'NOT_PLANNED', + author: null, + assignees: { nodes: [{ id: 'U3', login: 'assignee' }] }, + labels: { nodes: [{ name: 'bug' }] }, + createdAt: '2026-08-18T00:00:00Z', + updatedAt: '2026-08-18T01:00:00Z', + closedAt: '2026-08-18T02:00:00Z', + }; + } + + test('hydrates repository and issue resources in one GraphQL request', async () => { + await withServer(async server => { + server.enqueue(gitHubGraphQLStep({ + queryIncludes: 'HydrateGitHubResources', + assert: request => assert.deepStrictEqual(request.graphQl?.variables, { + owner0: 'octo', + repo0: 'repo', + owner1: 'octo', + repo1: 'repo', + number1: 7, + }), + response: gitHubGraphQLResponse({ + r0: graphQLRepository(), + r1: { + issue: graphQLIssue(), + }, + }), + })); + const { account, service } = setup(server); + const repositoryRef = { ...account, owner: 'octo', repo: 'repo' }; + const issueRef = { ...account, owner: 'octo', repo: 'repo', number: 7 }; + const repository = service.subscribeRepository(repositoryRef, { priority: 'visible' }); + const issue = service.subscribeIssue(issueRef, { priority: 'visible' }); + + await service.hydrateResources([ + { kind: 'repository', ref: repositoryRef }, + { kind: 'issue', ref: issueRef }, + ], signal()); + await service.hydrateResources([ + { kind: 'repository', ref: repositoryRef }, + { kind: 'issue', ref: issueRef }, + ], signal()); + + assert.deepStrictEqual({ + repository: repository.resource.state.get().value, + issue: issue.resource.state.get().value, + }, { + repository: { + id: 'R1', + owner: { id: 'U1', login: 'octo' }, + name: 'repo', + nameWithOwner: 'octo/repo', + language: 'TypeScript', + stars: 42, + defaultBranch: 'main', + private: false, + description: 'Repository', + url: 'https://example.test/octo/repo', + archived: false, + fork: false, + }, + issue: { + id: 'I7', + number: 7, + title: 'Issue', + body: 'Body', + url: 'https://example.test/octo/repo/issues/7', + state: 'closed', + stateReason: 'not_planned', + author: { login: 'ghost' }, + assignees: [{ id: 'U3', login: 'assignee' }], + labels: ['bug'], + createdAt: '2026-08-18T00:00:00Z', + updatedAt: '2026-08-18T01:00:00Z', + closedAt: '2026-08-18T02:00:00Z', + }, + }); + server.assertSatisfied(); + }); + }); + + test('does not overwrite a newer REST refresh with stale hydration data', async () => { + await withServer(async server => { + const hydrationStarted = new DeferredPromise(); + const releaseHydration = new DeferredPromise(); + const refreshStarted = new DeferredPromise(); + const releaseRefresh = new DeferredPromise(); + server.enqueue( + gitHubGraphQLStep({ + queryIncludes: 'HydrateGitHubResources', + assert: async () => hydrationStarted.complete(), + waitFor: releaseHydration.p, + response: gitHubGraphQLResponse({ r0: graphQLRepository() }), + }), + gitHubRestStep({ + method: 'GET', + path: '/repos/octo/repo', + assert: async () => refreshStarted.complete(), + waitFor: releaseRefresh.p, + response: gitHubJsonResponse(repositoryResponse('new-owner/new-repo')), + }), + ); + const { account, service } = setup(server); + const ref = { ...account, owner: 'octo', repo: 'repo' }; + const repository = service.subscribeRepository(ref, { priority: 'visible' }); + const hydration = service.hydrateResources([{ kind: 'repository', ref }], signal()); + await hydrationStarted.p; + + const refresh = repository.refresh(); + await releaseHydration.complete(); + await hydration; + await refreshStarted.p; + assert.strictEqual(repository.resource.state.get().status, 'loading'); + await releaseRefresh.complete(); + await refresh; + + assert.strictEqual(repository.resource.state.get().value?.nameWithOwner, 'new-owner/new-repo'); + server.assertSatisfied(); + }); + }); + test('shares repository and issue resources, canonicalizes aliases, and stops terminal issue polling', async () => { await withServer(async server => { const repositoryPolled = new DeferredPromise(); diff --git a/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts b/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts index 6cdd3e57293..d34b68a395f 100644 --- a/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts @@ -10,7 +10,7 @@ import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { IAgentHostConnectionsService } from '../../../../platform/agentHost/common/agentHostConnectionsService.js'; -import { AGENT_HOST_SESSION_LINK_PATTERN, AgentSessionLinkStatus, createAgentSessionLinkPresentation, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../../../platform/agentHost/common/openSessionLink.js'; +import { AGENT_HOST_CHAT_LINK_PATTERN, AGENT_HOST_SESSION_ONLY_LINK_PATTERN, AgentSessionLinkStatus, buildAgentSessionLinkPresentation, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../../../platform/agentHost/common/openSessionLink.js'; import { ILinkPresentation, ILinkPresentationService, ILinkPresentationWatcher } from '../../../../platform/dataChannel/common/dataChannel.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; @@ -50,10 +50,17 @@ export class OpenSessionLinkOpenerContribution extends Disposable implements IWo })); this._register(linkPresentationService.registerLinkPresentationProvider({ id: 'sessions.agentSessionLinkPresentation', - uriPattern: AGENT_HOST_SESSION_LINK_PATTERN, - initialKind: 'session', + uriPattern: AGENT_HOST_SESSION_ONLY_LINK_PATTERN, + kind: 'session', }, { - createLinkPresentationWatcher: resource => new AgentSessionLinkPresentationWatcher(resource, this._sessionsManagementService, this._connectionsService), + createLinkPresentationWatcher: resource => new AgentSessionLinkPresentationWatcher(resource, 'session', this._sessionsManagementService, this._connectionsService), + })); + this._register(linkPresentationService.registerLinkPresentationProvider({ + id: 'sessions.agentChatLinkPresentation', + uriPattern: AGENT_HOST_CHAT_LINK_PATTERN, + kind: 'chat', + }, { + createLinkPresentationWatcher: resource => new AgentSessionLinkPresentationWatcher(resource, 'chat', this._sessionsManagementService, this._connectionsService), })); // A session pill in chat output gets the same hover as the sessions list, // built from the live session this window already owns. @@ -93,6 +100,7 @@ class AgentSessionLinkPresentationWatcher extends Disposable implements ILinkPre constructor( resource: URI, + kind: 'session' | 'chat', sessionsManagementService: ISessionsManagementService, connectionsService: IAgentHostConnectionsService, ) { @@ -107,7 +115,7 @@ class AgentSessionLinkPresentationWatcher extends Disposable implements ILinkPre const session = backendSession ? findSession(backendSession, sessionsManagementService, connectionsService) : undefined; - return session ? readSessionState(session, chatId, reader) : undefined; + return session ? readSessionState(session, chatId, reader, kind) : undefined; }, ); } @@ -117,15 +125,16 @@ export function readSessionState( session: ISessionLinkState, chatId: string | undefined, reader: IReader, + kind: 'session' | 'chat' = chatId ? 'chat' : 'session', ): ILinkPresentation { const chat = findChat(session, chatId, reader); const sessionTitle = session.title.read(reader); const description = session.description.read(reader)?.value; - return createAgentSessionLinkPresentation( + return buildAgentSessionLinkPresentation( chat?.title.read(reader) ?? (chatId ? localize('agentChatLink.unresolvedTitle', "Chat · {0}", sessionTitle) : sessionTitle), description, sessionStatusName(chat?.status.read(reader) ?? session.status.read(reader)), - chatId ? 'chat' : 'session', + kind, ); } diff --git a/src/vs/workbench/api/browser/mainThreadDataChannels.ts b/src/vs/workbench/api/browser/mainThreadDataChannels.ts index c0b5d75617a..edb18aaf53a 100644 --- a/src/vs/workbench/api/browser/mainThreadDataChannels.ts +++ b/src/vs/workbench/api/browser/mainThreadDataChannels.ts @@ -8,7 +8,7 @@ import { Disposable, DisposableMap, DisposableStore } from '../../../base/common import { autorun, observableValue } from '../../../base/common/observable.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; -import { IDataChannelService, ILinkPresentation, ILinkPresentationProvider, ILinkPresentationService, ILinkPresentationWatcher, parseLinkPresentation } from '../../../platform/dataChannel/common/dataChannel.js'; +import { IDataChannelService, ILinkPresentation, ILinkPresentationProvider, ILinkPresentationService, ILinkPresentationWatcher, LinkPresentationKind, parseLinkPresentation } from '../../../platform/dataChannel/common/dataChannel.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; import { ExtHostContext, ExtHostDataChannelsShape, MainContext, MainThreadDataChannelsShape } from '../common/extHost.protocol.js'; @@ -37,18 +37,18 @@ export class MainThreadDataChannels extends Disposable implements MainThreadData id: rule.id, source: rule.uriPattern.source, flags: rule.uriPattern.flags, - initialKind: rule.initialKind, + kind: rule.kind, })) ); updateLinkPresentationRules(); this._register(this._linkPresentationService.onDidChangeLinkPresentationRules(updateLinkPresentationRules)); } - $createLinkPresentationWatcher(handle: number, providerId: string, resource: UriComponents): void { + $createLinkPresentationWatcher(handle: number, providerId: string, kind: LinkPresentationKind, resource: UriComponents): void { const watcher = this._linkPresentationService.createLinkPresentationWatcher(providerId, URI.revive(resource)); if (!watcher) { this._proxy.$acceptLinkPresentation(handle, { - kind: 'resource', + kind, status: { kind: 'error', label: localize('linkPresentation.unavailable', "Not available") }, tooltip: localize('linkPresentation.ruleMismatch', "The selected link presentation provider does not accept this resource."), ariaLabel: localize('linkPresentation.unavailableAriaLabel', "Link presentation is not available"), diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 0ae1f22d0f1..7f06743cc9d 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3738,7 +3738,7 @@ export interface MainThreadMcpShape { } export interface MainThreadDataChannelsShape extends IDisposable { - $createLinkPresentationWatcher(handle: number, providerId: string, resource: UriComponents): void; + $createLinkPresentationWatcher(handle: number, providerId: string, kind: LinkPresentationKind, resource: UriComponents): void; $disposeLinkPresentationWatcher(handle: number): void; $registerLinkPresentationProvider(handle: number, extensionId: string, providerId: string): void; $unregisterLinkPresentationProvider(handle: number): void; @@ -3747,7 +3747,7 @@ export interface MainThreadDataChannelsShape extends IDisposable { export interface ExtHostDataChannelsShape { $onDidReceiveData(channelId: string, data: unknown): void; - $acceptLinkPresentationRules(rules: readonly { id: string; source: string; flags: string; initialKind: LinkPresentationKind }[]): void; + $acceptLinkPresentationRules(rules: readonly { id: string; source: string; flags: string; kind: LinkPresentationKind }[]): void; $acceptLinkPresentation(handle: number, data: unknown): void; $createLinkPresentationWatcher(handle: number, providerHandle: number, resource: UriComponents): Promise; $disposeLinkPresentationWatcher(handle: number): void; diff --git a/src/vs/workbench/api/common/extHostDataChannels.ts b/src/vs/workbench/api/common/extHostDataChannels.ts index ec14ba3ebce..a791ecdc327 100644 --- a/src/vs/workbench/api/common/extHostDataChannels.ts +++ b/src/vs/workbench/api/common/extHostDataChannels.ts @@ -71,9 +71,9 @@ export class ExtHostDataChannels implements IExtHostDataChannels { throw new Error(`Link presentation provider '${providerId}' does not accept '${resourceString}'.`); } const cacheKey = `${providerId}\0${resourceString}`; - const cachedPresentation = this._getCachedLinkPresentation(cacheKey); + const cachedPresentation = this._getCachedLinkPresentation(cacheKey, rule.kind); const initialPresentation: vscode.LinkPresentationData = { - ...(cachedPresentation ?? { kind: rule.initialKind }), + ...(cachedPresentation ?? { kind: rule.kind }), isLoading: true, }; const handle = ExtHostDataChannels._linkPresentationWatcherHandlePool++; @@ -85,7 +85,7 @@ export class ExtHostDataChannels implements IExtHostDataChannels { presentation => this._cacheLinkPresentation(cacheKey, presentation), ); this._linkPresentationWatchers.set(handle, watcher); - this._proxy.$createLinkPresentationWatcher(handle, providerId, resource); + this._proxy.$createLinkPresentationWatcher(handle, providerId, rule.kind, resource); return watcher; } @@ -118,11 +118,11 @@ export class ExtHostDataChannels implements IExtHostDataChannels { this._channels.get(channelId)?._fireDidReceiveData(data); } - $acceptLinkPresentationRules(rules: readonly { id: string; source: string; flags: string; initialKind: LinkPresentationKind }[]): void { + $acceptLinkPresentationRules(rules: readonly { id: string; source: string; flags: string; kind: LinkPresentationKind }[]): void { this._linkPresentationRules = rules.map(rule => ({ id: rule.id, uriPattern: new RegExp(rule.source, rule.flags), - initialKind: rule.initialKind, + kind: rule.kind, })); this._onDidChangeLinkPresentationRules.fire(); } @@ -155,8 +155,12 @@ export class ExtHostDataChannels implements IExtHostDataChannels { } } - private _getCachedLinkPresentation(key: string): vscode.LinkPresentationData | undefined { + private _getCachedLinkPresentation(key: string, kind: LinkPresentationKind): vscode.LinkPresentationData | undefined { const presentation = this._linkPresentationCache.get(key); + if (presentation?.kind !== kind) { + this._linkPresentationCache.delete(key); + return undefined; + } if (presentation) { this._linkPresentationCache.delete(key); this._linkPresentationCache.set(key, presentation); diff --git a/src/vs/workbench/api/test/browser/mainThreadDataChannels.test.ts b/src/vs/workbench/api/test/browser/mainThreadDataChannels.test.ts index ec028df8c87..db0bd51cd04 100644 --- a/src/vs/workbench/api/test/browser/mainThreadDataChannels.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadDataChannels.test.ts @@ -26,6 +26,36 @@ import { SingleProxyRPCProtocol } from '../common/testRPCProtocol.js'; suite('MainThreadDataChannels', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + test('preserves the selected kind when the provider is no longer available', () => { + const presentations: unknown[] = []; + const extHostProxy = new class extends mock() { + override $acceptLinkPresentation(_handle: number, data: unknown): void { + presentations.push(data); + } + + override $acceptLinkPresentationRules(): void { } + }; + const mainThread = store.add(new MainThreadDataChannels( + SingleProxyRPCProtocol(extHostProxy), + store.add(new DataChannelService()), + store.add(new LinkPresentationService( + new NullExtensionService(), + new NullLogService(), + new TestConfigurationService(), + store.add(new TestStorageService()), + )), + )); + + mainThread.$createLinkPresentationWatcher(1, 'missing', 'pullRequest', URI.parse('https://example.com/pull/1')); + + assert.deepStrictEqual(presentations, [{ + kind: 'pullRequest', + status: { kind: 'error', label: 'Not available' }, + tooltip: 'The selected link presentation provider does not accept this resource.', + ariaLabel: 'Link presentation is not available', + }]); + }); + test('bridges core link presentation watchers and runtime enablement', async () => { const presentation = observableValue('presentation', { kind: 'session', @@ -43,7 +73,7 @@ suite('MainThreadDataChannels', () => { store.add(linkPresentationService.registerLinkPresentationProvider({ id: 'test.sessions', uriPattern: /^agent-host-session:/i, - initialKind: 'session', + kind: 'session', enablement: 'test.richLinks.enabled', }, { createLinkPresentationWatcher: () => { @@ -55,7 +85,7 @@ suite('MainThreadDataChannels', () => { }, })); - let acceptedRules: readonly { id: string; source: string; flags: string; initialKind: vscode.LinkPresentationKind }[] = []; + let acceptedRules: readonly { id: string; source: string; flags: string; kind: vscode.LinkPresentationKind }[] = []; const extHostHolder: { value?: ExtHostDataChannels } = {}; const extHostProxy: ExtHostDataChannelsShape = { $onDidReceiveData: (channelId, value) => extHostHolder.value?.$onDidReceiveData(channelId, value), @@ -105,7 +135,7 @@ suite('MainThreadDataChannels', () => { assert.deepStrictEqual({ values, acceptedRules, - linkPresentationRules: extHost.linkPresentationRules.map(rule => ({ id: rule.id, source: rule.uriPattern.source, flags: rule.uriPattern.flags, initialKind: rule.initialKind })), + linkPresentationRules: extHost.linkPresentationRules.map(rule => ({ id: rule.id, source: rule.uriPattern.source, flags: rule.uriPattern.flags, kind: rule.kind })), ruleChangeCount, providerWatcherCreateCount, providerWatcherDisposeCount, @@ -116,8 +146,8 @@ suite('MainThreadDataChannels', () => { { kind: 'session', title: 'Running session', status: { kind: 'pending', label: 'Working' }, isLoading: true }, { kind: 'session', title: 'Completed session', status: { kind: 'success', label: 'Completed' } }, ], - acceptedRules: [{ id: 'test.sessions', source: '^agent-host-session:', flags: 'i', initialKind: 'session' }], - linkPresentationRules: [{ id: 'test.sessions', source: '^agent-host-session:', flags: 'i', initialKind: 'session' }], + acceptedRules: [{ id: 'test.sessions', source: '^agent-host-session:', flags: 'i', kind: 'session' }], + linkPresentationRules: [{ id: 'test.sessions', source: '^agent-host-session:', flags: 'i', kind: 'session' }], ruleChangeCount: 2, providerWatcherCreateCount: 2, providerWatcherDisposeCount: 2, @@ -135,10 +165,10 @@ suite('MainThreadDataChannels', () => { store.add(linkPresentationService.declareExtensionLinkPresentationProvider('test.extension', { id: 'test.linkPresentations', uriPattern: '^https://github\\.com/[^/]+/[^/]+/pull/[0-9]+$', - initialKind: 'resource', + kind: 'pullRequest', enablement: 'test.richLinks.enabled', })); - let acceptedRules: readonly { id: string; source: string; flags: string; initialKind: vscode.LinkPresentationKind }[] = []; + let acceptedRules: readonly { id: string; source: string; flags: string; kind: vscode.LinkPresentationKind }[] = []; const extHostProxy: ExtHostDataChannelsShape = { $onDidReceiveData: (channelId, value) => extHost.$onDidReceiveData(channelId, value), $acceptLinkPresentationRules: rules => acceptedRules = rules, @@ -243,7 +273,7 @@ suite('MainThreadDataChannels', () => { id: 'test.linkPresentations', source: '^https:\\/\\/github\\.com\\/[^/]+\\/[^/]+\\/pull\\/[0-9]+$', flags: 'i', - initialKind: 'resource', + kind: 'pullRequest', }], secondInitialPresentation: { kind: 'pullRequest', @@ -270,7 +300,7 @@ suite('MainThreadDataChannels', () => { id: 'test.pullRequests', source: '^https://github\\.com/[^/]+/[^/]+/pull/[0-9]+$', flags: 'i', - initialKind: 'pullRequest', + kind: 'pullRequest', }]); const extension = { ...nullExtensionDescription, @@ -290,10 +320,18 @@ suite('MainThreadDataChannels', () => { }); firstWatcher.dispose(); const secondWatcher = store.add(extHost.createLinkPresentationWatcher(extension, 'test.pullRequests', resource)); + extHost.$acceptLinkPresentationRules([{ + id: 'test.pullRequests', + source: '^https://github\\.com/[^/]+/[^/]+/pull/[0-9]+$', + flags: 'i', + kind: 'issue', + }]); + const changedKindWatcher = store.add(extHost.createLinkPresentationWatcher(extension, 'test.pullRequests', resource)); assert.deepStrictEqual({ ruleInitialPresentation, cachedInitialPresentation: secondWatcher.presentation, + changedKindInitialPresentation: changedKindWatcher.presentation, }, { ruleInitialPresentation: { kind: 'pullRequest', @@ -305,6 +343,156 @@ suite('MainThreadDataChannels', () => { status: { kind: 'open', label: 'Open' }, isLoading: true, }, + changedKindInitialPresentation: { + kind: 'issue', + isLoading: true, + }, + }); + }); + + test('skips file link presentation providers', () => { + const linkPresentationService = store.add(new LinkPresentationService( + new NullExtensionService(), + new NullLogService(), + new TestConfigurationService(), + store.add(new TestStorageService()), + )); + let watcherCreateCount = 0; + store.add(linkPresentationService.registerLinkPresentationProvider({ + id: 'test.coreFiles', + uriPattern: /^file:/, + kind: 'file', + }, { + createLinkPresentationWatcher: () => { + watcherCreateCount++; + return { + presentation: observableValue('filePresentation', { kind: 'file' }), + dispose: () => { }, + }; + }, + })); + store.add(linkPresentationService.declareExtensionLinkPresentationProvider('test.extension', { + id: 'test.extensionFiles', + uriPattern: '^https://example\\.com/file$', + kind: 'file', + })); + + const fileResource = URI.parse('file:///workspace/file.ts'); + const remoteFileResource = URI.parse('https://example.com/file'); + assert.deepStrictEqual({ + rules: linkPresentationService.linkPresentationRules, + fileRule: linkPresentationService.getLinkPresentationRule(fileResource), + remoteFileRule: linkPresentationService.getLinkPresentationRule(remoteFileResource), + fileWatcher: linkPresentationService.createLinkPresentationWatcher('test.coreFiles', fileResource), + remoteFileWatcher: linkPresentationService.createLinkPresentationWatcher('test.extensionFiles', remoteFileResource), + watcherCreateCount, + }, { + rules: [], + fileRule: undefined, + remoteFileRule: undefined, + fileWatcher: undefined, + remoteFileWatcher: undefined, + watcherCreateCount: 0, + }); + }); + + test('rejects presentations that disagree with the registered kind', () => { + const linkPresentationService = store.add(new LinkPresentationService( + new NullExtensionService(), + new NullLogService(), + new TestConfigurationService(), + store.add(new TestStorageService()), + )); + const presentation = observableValue('presentation', { + kind: 'issue', + title: 'Wrong kind', + }); + store.add(linkPresentationService.registerLinkPresentationProvider({ + id: 'test.pullRequests', + uriPattern: /^https:\/\/example\.com\/pull\/[0-9]+$/, + kind: 'pullRequest', + }, { + createLinkPresentationWatcher: () => ({ + presentation, + dispose: () => { }, + }), + })); + const watcher = store.add(linkPresentationService.createLinkPresentationWatcher( + 'test.pullRequests', + URI.parse('https://example.com/pull/1'), + )!); + const values: (ILinkPresentation | undefined)[] = []; + store.add(autorun(reader => values.push(watcher.presentation.read(reader)))); + + presentation.set({ + kind: 'pullRequest', + title: 'Correct kind', + }, undefined); + + assert.deepStrictEqual(values, [ + { + kind: 'pullRequest', + status: { kind: 'error', label: 'Not available' }, + tooltip: 'The link presentation provider failed to load.', + ariaLabel: 'Link presentation is not available', + }, + { + kind: 'pullRequest', + title: 'Correct kind', + }, + ]); + }); + + test('replaces a restored presentation when the provider returns the wrong kind', () => { + const configurationService = new TestConfigurationService(); + const storageService = store.add(new TestStorageService()); + const resource = URI.parse('https://example.com/pull/1'); + const registration: ILinkPresentationProviderRegistration = { + id: 'test.pullRequests', + uriPattern: /^https:\/\/example\.com\/pull\/[0-9]+$/, + kind: 'pullRequest', + }; + const firstService = store.add(new LinkPresentationService( + new NullExtensionService(), + new NullLogService(), + configurationService, + storageService, + )); + store.add(firstService.registerLinkPresentationProvider(registration, { + createLinkPresentationWatcher: () => ({ + presentation: observableValue('firstPresentation', { + kind: 'pullRequest', + title: 'Cached pull request', + }), + dispose: () => { }, + }), + })); + const firstWatcher = store.add(firstService.createLinkPresentationWatcher(registration.id, resource)!); + firstWatcher.dispose(); + firstService.dispose(); + + const restoredService = store.add(new LinkPresentationService( + new NullExtensionService(), + new NullLogService(), + configurationService, + storageService, + )); + store.add(restoredService.registerLinkPresentationProvider(registration, { + createLinkPresentationWatcher: () => ({ + presentation: observableValue('wrongPresentation', { + kind: 'issue', + title: 'Wrong kind', + }), + dispose: () => { }, + }), + })); + const restoredWatcher = store.add(restoredService.createLinkPresentationWatcher(registration.id, resource)!); + + assert.deepStrictEqual(restoredWatcher.presentation.get(), { + kind: 'pullRequest', + status: { kind: 'error', label: 'Not available' }, + tooltip: 'The link presentation provider failed to load.', + ariaLabel: 'Link presentation is not available', }); }); @@ -315,7 +503,7 @@ suite('MainThreadDataChannels', () => { const registration: ILinkPresentationProviderRegistration = { id: 'test.pullRequests', uriPattern: /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[0-9]+$/i, - initialKind: 'pullRequest', + kind: 'pullRequest', enablement: 'test.richLinks.enabled', }; const firstService = store.add(new LinkPresentationService( diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/openSessionLinkOpener.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/openSessionLinkOpener.contribution.ts index 394f1955c50..d336fcacbc5 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/openSessionLinkOpener.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/openSessionLinkOpener.contribution.ts @@ -13,7 +13,7 @@ import { isEqual } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; import { LOCAL_AGENT_HOST_SCHEME_PREFIX } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; -import { AGENT_HOST_SESSION_LINK_PATTERN, AgentSessionLinkStatus, createAgentSessionLinkPresentation, parseOpenSessionLinkUri } from '../../../../../../platform/agentHost/common/openSessionLink.js'; +import { AGENT_HOST_SESSION_LINK_PATTERN, AgentSessionLinkStatus, buildAgentSessionLinkPresentation, parseOpenSessionLinkUri } from '../../../../../../platform/agentHost/common/openSessionLink.js'; import { ILinkPresentation, ILinkPresentationService, ILinkPresentationWatcher } from '../../../../../../platform/dataChannel/common/dataChannel.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; @@ -60,7 +60,7 @@ export class AgentHostOpenSessionLinkOpenerContribution extends Disposable imple this._register(linkPresentationService.registerLinkPresentationProvider({ id: 'workbench.agentSessionLinkPresentation', uriPattern: AGENT_HOST_SESSION_LINK_PATTERN, - initialKind: 'session', + kind: 'session', }, { createLinkPresentationWatcher: resource => { const clientResource = toClientSessionResource(resource); @@ -199,7 +199,7 @@ function toClientSessionResource(resource: URI | string): URI | undefined { function toSessionLinkPresentation(item: IChatSessionItem): ILinkPresentation { const description = typeof item.description === 'string' ? item.description : item.description?.value; - return createAgentSessionLinkPresentation(item.label, description, chatSessionStatusName(item.status)); + return buildAgentSessionLinkPresentation(item.label, description, chatSessionStatusName(item.status)); } /** diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts index 877a598d6f1..8aa755efa60 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts @@ -283,12 +283,12 @@ suite('ChatMarkdownContentPart', () => { const pullRequestRule = { id: 'test.linkPresentation', uriPattern: /^https:\/\/github\.com\/microsoft\/vscode\/pull\/1$/, - initialKind: 'pullRequest' as const, + kind: 'pullRequest' as const, }; const sessionRule = { id: 'test.agentSessionLinkPresentation', uriPattern: /^agent-host-session:\/\/copilotcli\/session-1(?:\?chat=chat-2)?$/, - initialKind: 'session' as const, + kind: 'session' as const, }; const presentation = observableValue('test.linkPresentation', { kind: 'pullRequest', diff --git a/src/vs/workbench/contrib/github/browser/githubLinkPresentation.contribution.ts b/src/vs/workbench/contrib/github/browser/githubLinkPresentation.contribution.ts new file mode 100644 index 00000000000..dbec4ea9161 --- /dev/null +++ b/src/vs/workbench/contrib/github/browser/githubLinkPresentation.contribution.ts @@ -0,0 +1,424 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, IObservable, observableValue } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { ILinkPresentation, ILinkPresentationProvider, ILinkPresentationService, ILinkPresentationStatus, ILinkPresentationWatcher, LinkPresentationKind } from '../../../../platform/dataChannel/common/dataChannel.js'; +import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IGitHubService } from '../../../../platform/github/common/githubService.js'; +import { GitHubHydratableResourceRef, GitHubIssue, GitHubIssueRef, GitHubRepository } from '../../../../platform/github/common/githubQueryService.js'; +import { FragmentState, PullRequestCheck, PullRequestCore, PullRequestRef, PullRequestSnapshot } from '../../../../platform/github/common/githubPullRequestService.js'; +import { GitHubRequestError } from '../../../../platform/github/common/githubTransport.js'; +import { GitHubAccountHandle, GitHubRequestErrorKind } from '../../../../platform/github/common/githubTypes.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; + +const githubRepositoryProviderId = 'workbench.github.repositoryLinkPresentation'; +const githubIssueProviderId = 'workbench.github.issueLinkPresentation'; +const githubPullRequestProviderId = 'workbench.github.pullRequestLinkPresentation'; + +type GitHubLinkTarget = + | { readonly kind: 'repository'; readonly owner: string; readonly repo: string } + | { readonly kind: 'issue'; readonly owner: string; readonly repo: string; readonly number: number } + | { readonly kind: 'pullRequest'; readonly owner: string; readonly repo: string; readonly number: number }; + +export class GitHubLinkPresentationContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.githubLinkPresentations'; + + private readonly _registrations = this._register(new MutableDisposable()); + private readonly _provider: GitHubLinkPresentationProvider; + + constructor( + @IGitHubService gitHubService: IGitHubService, + @ILinkPresentationService private readonly _linkPresentationService: ILinkPresentationService, + @IDefaultAccountService private readonly _defaultAccountService: IDefaultAccountService, + @ILogService logService: ILogService, + ) { + super(); + this._provider = this._register(new GitHubLinkPresentationProvider(gitHubService, logService)); + this._register(_defaultAccountService.onDidChangeDefaultAccount(() => this._registerProviders())); + this._registerProviders(); + } + + private _registerProviders(): void { + this._registrations.clear(); + const authority = URI.parse(this._defaultAccountService.resolveGitHubUrl('')).authority; + if (!authority) { + return; + } + + const escapedAuthority = escapeRegExpCharacters(authority); + const ownerAndRepo = `[^/?#]+/[^/?#]+`; + const suffix = '(?:[?#].*)?$'; + const registrations = new DisposableStore(); + registrations.add(this._linkPresentationService.registerLinkPresentationProvider({ + id: githubIssueProviderId, + uriPattern: new RegExp(`^https://${escapedAuthority}/${ownerAndRepo}/issues/[1-9]\\d*${suffix}`), + kind: 'issue', + }, this._provider)); + registrations.add(this._linkPresentationService.registerLinkPresentationProvider({ + id: githubPullRequestProviderId, + uriPattern: new RegExp(`^https://${escapedAuthority}/${ownerAndRepo}/pull/[1-9]\\d*${suffix}`), + kind: 'pullRequest', + }, this._provider)); + registrations.add(this._linkPresentationService.registerLinkPresentationProvider({ + id: githubRepositoryProviderId, + uriPattern: new RegExp(`^https://${escapedAuthority}/${ownerAndRepo}/?${suffix}`), + kind: 'repository', + }, this._provider)); + this._registrations.value = registrations; + } +} + +class GitHubLinkPresentationProvider extends Disposable implements ILinkPresentationProvider { + + private readonly _hydrator: GitHubLinkPresentationHydrator; + + constructor( + private readonly _gitHubService: IGitHubService, + private readonly _logService: ILogService, + ) { + super(); + this._hydrator = this._register(new GitHubLinkPresentationHydrator(_gitHubService, _logService)); + } + + createLinkPresentationWatcher(resource: URI): ILinkPresentationWatcher { + const target = parseGitHubLinkTarget(resource); + if (!target) { + throw new Error(`Unsupported GitHub link presentation resource: ${resource.toString(true)}`); + } + return new GitHubLinkPresentationWatcher(target, this._gitHubService, this._hydrator, this._logService); + } +} + +class GitHubLinkPresentationHydrator extends Disposable { + + private readonly _controller = new AbortController(); + private _pending: { + readonly resource: GitHubHydratableResourceRef; + readonly resolve: () => void; + readonly reject: (error: unknown) => void; + }[] = []; + private _scheduled = false; + + constructor( + private readonly _gitHubService: IGitHubService, + private readonly _logService: ILogService, + ) { + super(); + this._register(toDisposable(() => this._controller.abort())); + } + + hydrate(target: GitHubLinkTarget, account: GitHubAccountHandle): Promise { + if (target.kind === 'pullRequest') { + return Promise.resolve(); + } + const resource: GitHubHydratableResourceRef = target.kind === 'repository' + ? { kind: 'repository', ref: { ...account, owner: target.owner, repo: target.repo } } + : { kind: 'issue', ref: { ...account, owner: target.owner, repo: target.repo, number: target.number } }; + const promise = new Promise((resolve, reject) => this._pending.push({ resource, resolve, reject })); + if (!this._scheduled) { + this._scheduled = true; + queueMicrotask(() => void this._flush()); + } + return promise; + } + + private async _flush(): Promise { + this._scheduled = false; + const pending = this._pending; + this._pending = []; + if (pending.length === 0) { + return; + } + + const groups = new Map(); + for (const item of pending) { + const key = `${item.resource.ref.host.toLowerCase()}\x00${item.resource.ref.accountId}`; + const group = groups.get(key); + if (group) { + group.push(item); + } else { + groups.set(key, [item]); + } + } + + await Promise.all([...groups.values()].map(async group => { + const resources = [...new Map(group.map(item => [ + hydrationResourceKey(item.resource), + item.resource, + ])).values()]; + try { + await this._gitHubService.query.hydrateResources(resources, this._controller.signal); + this._logService.trace(`[GitHubLinkPresentation] Hydrated ${resources.length} resource(s) in one request`); + for (const item of group) { + item.resolve(); + } + } catch (error) { + for (const item of group) { + item.reject(error); + } + } + })); + } + + override dispose(): void { + for (const item of this._pending) { + item.reject(new Error('GitHub link presentation hydrator was disposed')); + } + this._pending = []; + super.dispose(); + } +} + +class GitHubLinkPresentationWatcher extends Disposable implements ILinkPresentationWatcher { + + private readonly _presentation = observableValue(this, undefined); + readonly presentation: IObservable = this._presentation; + + private readonly _activeSubscription = this._register(new MutableDisposable()); + private _generation = 0; + + constructor( + private readonly _target: GitHubLinkTarget, + private readonly _gitHubService: IGitHubService, + private readonly _hydrator: GitHubLinkPresentationHydrator, + private readonly _logService: ILogService, + ) { + super(); + this._register(_gitHubService.credentials.onDidInvalidate(() => this._initialize())); + this._initialize(); + } + + private _initialize(): void { + const generation = ++this._generation; + const target = this._target; + const store = new DisposableStore(); + const controller = new AbortController(); + store.add(toDisposable(() => controller.abort())); + this._activeSubscription.value = store; + + void this._initializeSubscription(target, generation, controller, store); + } + + private async _initializeSubscription(target: GitHubLinkTarget, generation: number, controller: AbortController, store: DisposableStore): Promise { + try { + const credential = await this._gitHubService.credentials.getCredential(controller.signal); + if (controller.signal.aborted || generation !== this._generation) { + return; + } + const account = credential.account; + void this._hydrator.hydrate(target, account).catch(error => { + this._logService.trace(`[GitHubLinkPresentation] Bulk hydration failed for ${formatTarget(target)}; falling back to resource fetch`, error); + }); + switch (target.kind) { + case 'repository': { + const subscription = store.add(this._gitHubService.query.subscribeRepository({ + ...account, + owner: target.owner, + repo: target.repo, + }, { priority: 'visible' })); + store.add(autorun(reader => this._presentation.set( + repositoryPresentation(target, subscription.resource.state.read(reader)), + undefined, + ))); + break; + } + case 'issue': { + const ref: GitHubIssueRef = { ...account, owner: target.owner, repo: target.repo, number: target.number }; + const subscription = store.add(this._gitHubService.query.subscribeIssue(ref, { priority: 'visible' })); + store.add(autorun(reader => this._presentation.set( + issuePresentation(target, subscription.resource.state.read(reader)), + undefined, + ))); + break; + } + case 'pullRequest': { + const ref: PullRequestRef = { ...account, owner: target.owner, repo: target.repo, number: target.number }; + const subscription = store.add(this._gitHubService.pullRequests.subscribePullRequest(ref, { + priority: 'visible', + core: true, + checks: { includeOptional: true }, + })); + store.add(autorun(reader => this._presentation.set( + pullRequestPresentation(target, subscription.resource.snapshot.read(reader)), + undefined, + ))); + break; + } + } + } catch (error) { + if (controller.signal.aborted || generation !== this._generation) { + return; + } + this._logService.trace(`[GitHubLinkPresentation] Failed to resolve ${formatTarget(this._target)}`, error); + this._presentation.set(failurePresentation(this._target.kind, error instanceof GitHubRequestError ? error.kind : undefined), undefined); + } + } +} + +function repositoryPresentation(target: Extract, state: FragmentState): ILinkPresentation | undefined { + if (!state.value) { + return state.status === 'error' ? failurePresentation(target.kind, state.error?.kind) : undefined; + } + const details = [ + state.value.language, + state.value.stars === undefined ? undefined : localize('github.repository.stars', "{0} stars", formatCount(state.value.stars)), + ].filter((value): value is string => !!value); + return { + kind: 'repository', + detail: details.length ? details.join(' · ') : undefined, + tooltip: `${target.owner}/${target.repo}`, + ariaLabel: localize('github.repository.ariaLabel', "GitHub repository {0} slash {1}", target.owner, target.repo), + ...(state.status !== 'ready' ? { isLoading: true } : {}), + }; +} + +function issuePresentation(target: Extract, state: FragmentState): ILinkPresentation | undefined { + if (!state.value) { + return state.status === 'error' ? failurePresentation(target.kind, state.error?.kind) : undefined; + } + const status = issueStatus(state.value); + return { + kind: 'issue', + title: state.value.title, + reference: `#${target.number}`, + status, + tooltip: `${target.owner}/${target.repo}#${target.number} · ${status.label}`, + ariaLabel: localize('github.issue.ariaLabel', "Issue {0} slash {1} number {2}, {3}: {4}", target.owner, target.repo, target.number, status.label, state.value.title), + ...(state.status !== 'ready' ? { isLoading: true } : {}), + }; +} + +function pullRequestPresentation(target: Extract, snapshot: PullRequestSnapshot): ILinkPresentation | undefined { + const core = snapshot.core; + if (!core.value) { + return core.status === 'error' ? failurePresentation(target.kind, core.error?.kind) : undefined; + } + const status = pullRequestStatus(core.value); + const checksStatus = status.kind === 'open' || status.kind === 'draft' + ? pullRequestChecksStatus(snapshot.checks.value?.checks) + : undefined; + return { + kind: 'pullRequest', + title: core.value.title, + reference: `#${target.number}`, + status, + secondaryStatus: checksStatus, + tooltip: [target.owner + '/' + target.repo + '#' + target.number, status.label, checksStatus?.label].filter(Boolean).join(' · '), + ariaLabel: checksStatus + ? localize('github.pullRequest.ariaLabelWithChecks', "Pull request {0} slash {1} number {2}, {3}, {4}: {5}", target.owner, target.repo, target.number, status.label, checksStatus.label, core.value.title) + : localize('github.pullRequest.ariaLabel', "Pull request {0} slash {1} number {2}, {3}: {4}", target.owner, target.repo, target.number, status.label, core.value.title), + ...(core.status !== 'ready' ? { isLoading: true } : {}), + }; +} + +function issueStatus(issue: GitHubIssue): ILinkPresentationStatus { + if (issue.state === 'open') { + return { kind: 'open', label: localize('github.status.open', "Open") }; + } + return issue.stateReason === 'not_planned' + ? { kind: 'notPlanned', label: localize('github.status.notPlanned', "Not planned") } + : { kind: 'closed', label: localize('github.status.closed', "Closed") }; +} + +function pullRequestStatus(pullRequest: PullRequestCore): ILinkPresentationStatus { + if (pullRequest.state === 'merged') { + return { kind: 'merged', label: localize('github.status.merged', "Merged") }; + } + if (pullRequest.draft) { + return { kind: 'draft', label: localize('github.status.draft', "Draft") }; + } + return pullRequest.state === 'closed' + ? { kind: 'closed', label: localize('github.status.closed', "Closed") } + : { kind: 'open', label: localize('github.status.open', "Open") }; +} + +function pullRequestChecksStatus(checks: readonly PullRequestCheck[] | undefined): ILinkPresentationStatus | undefined { + if (!checks?.length) { + return undefined; + } + if (checks.some(check => check.type === 'checkRun' + ? check.status !== 'COMPLETED' + : check.status === 'PENDING' || check.status === 'EXPECTED')) { + return { kind: 'pending', label: localize('github.checks.running', "Checks running") }; + } + if (checks.some(check => check.type === 'checkRun' + ? check.conclusion === 'FAILURE' + || check.conclusion === 'TIMED_OUT' + || check.conclusion === 'CANCELLED' + || check.conclusion === 'ACTION_REQUIRED' + || check.conclusion === 'STARTUP_FAILURE' + : check.status === 'FAILURE' || check.status === 'ERROR')) { + return { kind: 'error', label: localize('github.checks.failed', "Checks failed") }; + } + return { kind: 'success', label: localize('github.checks.passed', "Checks passed") }; +} + +function failurePresentation(kind: LinkPresentationKind, errorKind: GitHubRequestErrorKind | undefined): ILinkPresentation { + const label = errorKind === 'rateLimit' + ? localize('github.failure.rateLimited', "Rate limited") + : errorKind === 'authentication' + ? localize('github.failure.authenticationRequired', "Authentication required") + : errorKind === 'authorization' + ? localize('github.failure.accessDenied', "Access denied") + : errorKind === 'notFound' + ? localize('github.failure.notFound', "Not found") + : localize('github.failure.unavailable', "Not available"); + return { + kind, + status: { kind: 'error', label }, + tooltip: localize('github.failure.tooltip', "GitHub could not load this resource: {0}", label), + ariaLabel: localize('github.failure.ariaLabel', "GitHub {0} lookup failed: {1}", kind, label), + }; +} + +function parseGitHubLinkTarget(resource: URI): GitHubLinkTarget | undefined { + if (resource.scheme !== 'https') { + return undefined; + } + const segments = resource.path.split('/').filter(Boolean); + if (segments.length === 2) { + return { kind: 'repository', owner: segments[0], repo: segments[1] }; + } + if (segments.length !== 4) { + return undefined; + } + const number = Number(segments[3]); + if (!Number.isSafeInteger(number) || number <= 0) { + return undefined; + } + if (segments[2] === 'issues') { + return { kind: 'issue', owner: segments[0], repo: segments[1], number }; + } + if (segments[2] === 'pull') { + return { kind: 'pullRequest', owner: segments[0], repo: segments[1], number }; + } + return undefined; +} + +function formatTarget(target: GitHubLinkTarget): string { + return target.kind === 'repository' + ? `${target.owner}/${target.repo}` + : `${target.owner}/${target.repo}#${target.number}`; +} + +function hydrationResourceKey(resource: GitHubHydratableResourceRef): string { + const suffix = resource.kind === 'issue' ? `#${resource.ref.number}` : ''; + return `${resource.kind}:${resource.ref.owner.toLowerCase()}/${resource.ref.repo.toLowerCase()}${suffix}`; +} + +function formatCount(value: number): string { + return value >= 1000 ? `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k` : String(value); +} + +function escapeRegExpCharacters(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +registerWorkbenchContribution2(GitHubLinkPresentationContribution.ID, GitHubLinkPresentationContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/github/test/browser/githubLinkPresentation.test.ts b/src/vs/workbench/contrib/github/test/browser/githubLinkPresentation.test.ts new file mode 100644 index 00000000000..b28f6e4631d --- /dev/null +++ b/src/vs/workbench/contrib/github/test/browser/githubLinkPresentation.test.ts @@ -0,0 +1,266 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { IDefaultAccount } from '../../../../../base/common/defaultAccount.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ILinkPresentationProvider, ILinkPresentationProviderRegistration, ILinkPresentationService } from '../../../../../platform/dataChannel/common/dataChannel.js'; +import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IGitHubService } from '../../../../../platform/github/common/githubService.js'; +import { GitHubIssue, GitHubRepository } from '../../../../../platform/github/common/githubQueryService.js'; +import { FragmentState, PullRequestSnapshot } from '../../../../../platform/github/common/githubPullRequestService.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { GitHubLinkPresentationContribution } from '../../browser/githubLinkPresentation.contribution.js'; + +suite('GitHub link presentations', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('maps shared GitHub resources to accessible link presentations', async () => { + const linkPresentationService = new TestLinkPresentationService(); + const hydrationBatches: string[][] = []; + store.add(new GitHubLinkPresentationContribution( + createGitHubService(resources => hydrationBatches.push(resources.map(resource => resource.kind))), + linkPresentationService, + new class extends mock() { + override readonly onDidChangeDefaultAccount = Event.None; + override resolveGitHubUrl(path: string): string { + return `https://github.com/${path}`; + } + }(), + new NullLogService(), + )); + + const resources = [ + URI.parse('https://github.com/microsoft/vscode'), + URI.parse('https://github.com/microsoft/vscode/issues/7'), + URI.parse('https://github.com/microsoft/vscode/pull/8'), + ]; + const watchers = resources.map(resource => store.add(linkPresentationService.createWatcher(resource))); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + assert.deepStrictEqual({ + hydrationBatches, + presentations: watchers.map(watcher => watcher.presentation.get()), + }, { + hydrationBatches: [['repository', 'issue']], + presentations: [{ + kind: 'repository', + detail: 'TypeScript · 170k stars', + tooltip: 'microsoft/vscode', + ariaLabel: 'GitHub repository microsoft slash vscode', + }, + { + kind: 'issue', + title: 'Issue title', + reference: '#7', + status: { kind: 'notPlanned', label: 'Not planned' }, + tooltip: 'microsoft/vscode#7 · Not planned', + ariaLabel: 'Issue microsoft slash vscode number 7, Not planned: Issue title', + }, + { + kind: 'pullRequest', + title: 'Pull request title', + reference: '#8', + status: { kind: 'open', label: 'Open' }, + secondaryStatus: { kind: 'error', label: 'Checks failed' }, + tooltip: 'microsoft/vscode#8 · Open · Checks failed', + ariaLabel: 'Pull request microsoft slash vscode number 8, Open, Checks failed: Pull request title', + }], + }); + }); + + test('re-registers providers when the default account changes', () => { + const linkPresentationService = new TestLinkPresentationService(); + const onDidChangeDefaultAccount = store.add(new Emitter()); + let authority = 'github.com'; + store.add(new GitHubLinkPresentationContribution( + createGitHubService(() => { }), + linkPresentationService, + new class extends mock() { + override readonly onDidChangeDefaultAccount = onDidChangeDefaultAccount.event; + override resolveGitHubUrl(path: string): string { + return `https://${authority}/${path}`; + } + }(), + new NullLogService(), + )); + + const before = linkPresentationService.hasProvider(URI.parse('https://github.com/microsoft/vscode/issues/1')); + authority = 'github.example.com'; + onDidChangeDefaultAccount.fire(null); + + assert.deepStrictEqual({ + before, + oldAuthority: linkPresentationService.hasProvider(URI.parse('https://github.com/microsoft/vscode/issues/1')), + newAuthority: linkPresentationService.hasProvider(URI.parse('https://github.example.com/microsoft/vscode/issues/1')), + }, { + before: true, + oldAuthority: false, + newAuthority: true, + }); + }); +}); + +class TestLinkPresentationService extends mock() { + + private readonly _providers: { readonly registration: ILinkPresentationProviderRegistration; readonly provider: ILinkPresentationProvider }[] = []; + + override registerLinkPresentationProvider(registration: ILinkPresentationProviderRegistration, provider: ILinkPresentationProvider): IDisposable { + if (this._providers.some(candidate => candidate.registration.id === registration.id)) { + throw new Error(`Duplicate provider '${registration.id}'.`); + } + const entry = { registration, provider }; + this._providers.push(entry); + return toDisposable(() => { + const index = this._providers.indexOf(entry); + if (index >= 0) { + this._providers.splice(index, 1); + } + }); + } + + createWatcher(resource: URI) { + const value = resource.toString(true); + const entry = this._providers.find(candidate => candidate.registration.uriPattern.test(value)); + assert.ok(entry); + return entry.provider.createLinkPresentationWatcher(resource); + } + + hasProvider(resource: URI): boolean { + const value = resource.toString(true); + return this._providers.some(candidate => candidate.registration.uriPattern.test(value)); + } +} + +function createGitHubService(onHydrate: (resources: Parameters[0]) => void): IGitHubService { + const ready = (value: T): FragmentState => ({ value, status: 'ready', complete: true }); + const missing: FragmentState = { status: 'missing', complete: false }; + const pullRequestSnapshot: PullRequestSnapshot = { + ref: { host: 'api.github.com', accountId: '1', owner: 'microsoft', repo: 'vscode', number: 8 }, + generation: 1, + headGeneration: 1, + core: ready({ + repositoryNameWithOwner: 'microsoft/vscode', + number: 8, + title: 'Pull request title', + url: 'https://github.com/microsoft/vscode/pull/8', + state: 'open', + draft: false, + headSha: 'head', + headRef: 'feature', + baseSha: 'base', + baseRef: 'main', + }), + topLevelComments: missing, + submittedReviews: missing, + inlineComments: missing, + reviewThreads: missing, + checks: ready({ + headSha: 'head', + checks: [{ + id: 'check', + type: 'checkRun', + name: 'test', + status: 'COMPLETED', + conclusion: 'FAILURE', + }, { + id: 'status', + type: 'statusContext', + name: 'status', + status: 'SUCCESS', + }], + requirednessComplete: true, + expectedSuites: [], + expectedSuitesComplete: true, + }), + mergeability: missing, + participants: missing, + }; + + return new class extends mock() { + override readonly credentials = { + onDidInvalidate: Event.None, + getCredential: async () => ({ + account: { host: 'api.github.com', accountId: '1' }, + token: 'token', + generation: 1, + signal: new AbortController().signal, + }), + resolveCredential: async () => { throw new Error('Not implemented'); }, + handleRequestError: () => { }, + }; + override readonly query = new class extends mock() { + override async hydrateResources(resources: Parameters[0]): Promise { + onHydrate(resources); + } + override subscribeRepository(ref: Parameters[0]) { + return { + resource: { + ref, + state: observableValue('repository', ready({ + owner: { login: 'microsoft' }, + name: 'vscode', + nameWithOwner: 'microsoft/vscode', + language: 'TypeScript', + stars: 170_000, + defaultBranch: 'main', + private: false, + description: '', + url: 'https://github.com/microsoft/vscode', + archived: false, + fork: false, + })), + }, + update: () => { }, + refresh: async () => { }, + dispose: () => { }, + }; + } + override subscribeIssue(ref: Parameters[0]) { + return { + resource: { + ref, + state: observableValue('issue', ready({ + number: 7, + title: 'Issue title', + body: '', + url: 'https://github.com/microsoft/vscode/issues/7', + state: 'closed', + stateReason: 'not_planned', + author: { login: 'author' }, + assignees: [], + labels: [], + createdAt: '2026-08-18T00:00:00Z', + updatedAt: '2026-08-18T00:00:00Z', + })), + }, + update: () => { }, + refresh: async () => { }, + dispose: () => { }, + }; + } + }(); + override readonly pullRequests = new class extends mock() { + override subscribePullRequest() { + return { + resource: { + ref: pullRequestSnapshot.ref, + snapshot: observableValue('pullRequest', pullRequestSnapshot), + }, + update: () => { }, + refresh: async () => { }, + dispose: () => { }, + }; + } + }(); + }(); +} diff --git a/src/vs/workbench/services/dataChannel/browser/dataChannelService.ts b/src/vs/workbench/services/dataChannel/browser/dataChannelService.ts index 0d12497b3eb..eec29444c07 100644 --- a/src/vs/workbench/services/dataChannel/browser/dataChannelService.ts +++ b/src/vs/workbench/services/dataChannel/browser/dataChannelService.ts @@ -26,7 +26,7 @@ const uriPatternLengthLimit = 1_024; export interface ILinkPresentationProviderContribution { readonly id: string; readonly uriPattern: string; - readonly initialKind: LinkPresentationKind; + readonly kind: LinkPresentationKind; readonly enablement?: string; } @@ -49,7 +49,7 @@ interface ICoreLinkPresentationProvider { interface ISelectedLinkPresentationProvider { readonly id: string; readonly regexp: RegExp; - readonly initialKind: LinkPresentationKind; + readonly kind: LinkPresentationKind; readonly enablement?: string; readonly coreProvider?: ILinkPresentationProvider; readonly extensionId?: string; @@ -60,7 +60,7 @@ interface ICachedLinkPresentation { readonly presentation: ILinkPresentation; } -export const linkPresentationProviderInitialKinds: LinkPresentationKind[] = [ +export const linkPresentationProviderKinds: LinkPresentationKind[] = [ 'resource', 'issue', 'pullRequest', @@ -81,7 +81,7 @@ const linkPresentationProviderExtensionPoint = ExtensionsRegistry.registerExtens items: { type: 'object', additionalProperties: false, - required: ['id', 'uriPattern', 'initialKind'], + required: ['id', 'uriPattern', 'kind'], properties: { id: { type: 'string', @@ -91,10 +91,10 @@ const linkPresentationProviderExtensionPoint = ExtensionsRegistry.registerExtens type: 'string', description: localize('linkPresentationProvider.uriPattern', "Anchored regular expression matched against the canonical URI string before the extension is activated."), }, - initialKind: { + kind: { type: 'string', - enum: linkPresentationProviderInitialKinds, - description: localize('linkPresentationProvider.initialKind', "The initial semantic kind shown while the provider resolves its first presentation."), + enum: linkPresentationProviderKinds, + description: localize('linkPresentationProvider.kind', "The semantic kind produced by this provider."), }, enablement: { type: 'string', @@ -151,11 +151,11 @@ export class LinkPresentationService extends Disposable implements ILinkPresenta get linkPresentationRules(): readonly ILinkPresentationRule[] { return [ ...Array.from(this._coreProviders.values()) - .filter(provider => this._isEnabled(provider.registration.enablement)) - .map(provider => ({ id: provider.registration.id, uriPattern: provider.regexp, initialKind: provider.registration.initialKind })), + .filter(provider => this._isProviderEnabled(provider.registration.kind, provider.registration.enablement)) + .map(provider => ({ id: provider.registration.id, uriPattern: provider.regexp, kind: provider.registration.kind })), ...Array.from(this._declaredExtensionProviders.values()) - .filter(provider => this._isEnabled(provider.enablement)) - .map(provider => ({ id: provider.id, uriPattern: provider.regexp, initialKind: provider.initialKind })), + .filter(provider => this._isProviderEnabled(provider.kind, provider.enablement)) + .map(provider => ({ id: provider.id, uriPattern: provider.regexp, kind: provider.kind })), ].map(rule => ({ ...rule, uriPattern: normalizeUriPattern(rule.uriPattern) })); } @@ -285,7 +285,7 @@ export class LinkPresentationService extends Disposable implements ILinkPresenta getLinkPresentationRule(resource: URI): ILinkPresentationRule | undefined { const provider = this._selectProvider(resource); - return provider ? { id: provider.id, uriPattern: provider.regexp, initialKind: provider.initialKind } : undefined; + return provider ? { id: provider.id, uriPattern: provider.regexp, kind: provider.kind } : undefined; } createLinkPresentationWatcher(providerId: string, resource: URI): ILinkPresentationWatcher | undefined { @@ -326,13 +326,13 @@ export class LinkPresentationService extends Disposable implements ILinkPresenta return; } - const cached = this._getCachedPresentation(entry.key, provider.id); + const cached = this._getCachedPresentation(entry.key, provider.id, provider.kind); entry.setPresentation(cached ? { ...cached, isLoading: true } : undefined); if (provider.coreProvider) { try { - this._attachProviderWatcher(entry, provider.coreProvider.createLinkPresentationWatcher(entry.resource), generation); + this._attachProviderWatcher(entry, provider, provider.coreProvider.createLinkPresentationWatcher(entry.resource), generation); } catch (error) { - this._handleProviderError(entry, generation, error); + this._handleProviderError(entry, generation, provider.kind, error); } return; } @@ -346,13 +346,13 @@ export class LinkPresentationService extends Disposable implements ILinkPresenta if (!registration || !provider.extensionId || !ExtensionIdentifier.equals(registration.extensionId, provider.extensionId)) { throw new Error(`Extension '${provider.extensionId}' did not register link presentation provider '${provider.id}'.`); } - this._attachProviderWatcher(entry, registration.provider.createLinkPresentationWatcher(entry.resource), generation); + this._attachProviderWatcher(entry, provider, registration.provider.createLinkPresentationWatcher(entry.resource), generation); } catch (error) { - this._handleProviderError(entry, generation, error); + this._handleProviderError(entry, generation, provider.kind, error); } } - private _attachProviderWatcher(entry: SharedLinkPresentationEntry, watcher: ILinkPresentationWatcher, generation: number): void { + private _attachProviderWatcher(entry: SharedLinkPresentationEntry, provider: ISelectedLinkPresentationProvider, watcher: ILinkPresentationWatcher, generation: number): void { if (!entry.isCurrent(generation)) { watcher.dispose(); return; @@ -362,6 +362,19 @@ export class LinkPresentationService extends Disposable implements ILinkPresenta store.add(autorun(reader => { const presentation = watcher.presentation.read(reader); if (presentation && entry.isCurrent(generation) && entry.providerId) { + if (presentation.kind !== provider.kind) { + entry.setPresentation(undefined); + if (this._cache.delete(entry.key)) { + this._persistCache(); + } + this._handleProviderError( + entry, + generation, + provider.kind, + new Error(`Link presentation provider '${provider.id}' produced kind '${presentation.kind}', but registered kind '${provider.kind}'.`), + ); + return; + } entry.setPresentation(presentation); this._cachePresentation(entry.key, entry.providerId, presentation); } @@ -369,14 +382,14 @@ export class LinkPresentationService extends Disposable implements ILinkPresenta entry.attach(store, generation); } - private _handleProviderError(entry: SharedLinkPresentationEntry, generation: number, error: unknown): void { + private _handleProviderError(entry: SharedLinkPresentationEntry, generation: number, kind: LinkPresentationKind, error: unknown): void { if (!entry.isCurrent(generation)) { return; } this._logService.error(`Failed to create a link presentation watcher for '${entry.resource.toString(true)}'.`, error); if (!entry.presentation.get()) { entry.setPresentation({ - kind: 'resource', + kind, status: { kind: 'error', label: localize('linkPresentation.unavailable', "Not available") }, tooltip: localize('linkPresentation.unavailableTooltip', "The link presentation provider failed to load."), ariaLabel: localize('linkPresentation.unavailableAriaLabel', "Link presentation is not available"), @@ -390,11 +403,11 @@ export class LinkPresentationService extends Disposable implements ILinkPresenta if (providerId !== undefined && candidate.registration.id !== providerId) { continue; } - if (this._isEnabled(candidate.registration.enablement) && matchesUriPattern(candidate.regexp, value)) { + if (this._isProviderEnabled(candidate.registration.kind, candidate.registration.enablement) && matchesUriPattern(candidate.regexp, value)) { return { id: candidate.registration.id, regexp: candidate.regexp, - initialKind: candidate.registration.initialKind, + kind: candidate.registration.kind, enablement: candidate.registration.enablement, coreProvider: candidate.provider, }; @@ -404,11 +417,11 @@ export class LinkPresentationService extends Disposable implements ILinkPresenta if (providerId !== undefined && candidate.id !== providerId) { continue; } - if (this._isEnabled(candidate.enablement) && matchesUriPattern(candidate.regexp, value)) { + if (this._isProviderEnabled(candidate.kind, candidate.enablement) && matchesUriPattern(candidate.regexp, value)) { return { id: candidate.id, regexp: candidate.regexp, - initialKind: candidate.initialKind, + kind: candidate.kind, enablement: candidate.enablement, extensionId: candidate.extensionId, }; @@ -421,11 +434,21 @@ export class LinkPresentationService extends Disposable implements ILinkPresenta return !enablement || this._configurationService.getValue(enablement) === true; } - private _getCachedPresentation(key: string, providerId: string): ILinkPresentation | undefined { + private _isProviderEnabled(kind: LinkPresentationKind, enablement: string | undefined): boolean { + // File presentations are temporarily disabled until they have a dedicated setting. + return kind !== 'file' && this._isEnabled(enablement); + } + + private _getCachedPresentation(key: string, providerId: string, kind: LinkPresentationKind): ILinkPresentation | undefined { const cached = this._cache.get(key); if (!cached || cached.providerId !== providerId) { return undefined; } + if (cached.presentation.kind !== kind) { + this._cache.delete(key); + this._persistCache(); + return undefined; + } this._cache.delete(key); this._cache.set(key, cached); return cached.presentation; @@ -511,7 +534,7 @@ class SharedLinkPresentationEntry extends Disposable { } disposed = true; this._references--; - if (this._references === 0) { + if (this._references === 0 && !this._store.isDisposed) { this._releaseTimer.value = disposableTimeout(this._onDidBecomeUnused, watcherReleaseDelay); } }, diff --git a/src/vs/workbench/services/dataChannel/test/browser/dataChannelService.test.ts b/src/vs/workbench/services/dataChannel/test/browser/dataChannelService.test.ts index ad5672937ff..798b0c07dbd 100644 --- a/src/vs/workbench/services/dataChannel/test/browser/dataChannelService.test.ts +++ b/src/vs/workbench/services/dataChannel/test/browser/dataChannelService.test.ts @@ -5,13 +5,13 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { linkPresentationProviderInitialKinds } from '../../browser/dataChannelService.js'; +import { linkPresentationProviderKinds } from '../../browser/dataChannelService.js'; suite('DataChannelService', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('link presentation contribution supports chat initial kind', () => { - assert.ok(linkPresentationProviderInitialKinds.includes('chat')); + test('link presentation contribution supports chat kind', () => { + assert.ok(linkPresentationProviderKinds.includes('chat')); }); }); diff --git a/src/vs/workbench/services/github/browser/githubService.ts b/src/vs/workbench/services/github/browser/githubService.ts new file mode 100644 index 00000000000..67bef5bbb71 --- /dev/null +++ b/src/vs/workbench/services/github/browser/githubService.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../base/common/event.js'; +import { deriveGitHubEndpoints } from '../../../../platform/agentHost/common/githubEndpoints.js'; +import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; +import { GitHubService, IGitHubService } from '../../../../platform/github/common/githubService.js'; +import { IGitHubEndpointProvider, IGitHubTokenProvider } from '../../../../platform/github/common/githubTypes.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IAuthenticationService } from '../../authentication/common/authentication.js'; + +class WorkbenchGitHubEndpointProvider implements IGitHubEndpointProvider { + + readonly onDidChange: Event; + + constructor(private readonly _defaultAccountService: IDefaultAccountService) { + this.onDidChange = Event.map(_defaultAccountService.onDidChangeDefaultAccount, () => undefined); + } + + getApiBaseUri(): string { + return this._getEndpoints().apiBaseUri; + } + + getGraphQlUri(): string { + return this._getEndpoints().graphQlUri; + } + + private _getEndpoints() { + const authenticationProvider = this._defaultAccountService.getDefaultAccountAuthenticationProvider(); + const enterpriseUri = authenticationProvider.enterprise ? this._defaultAccountService.resolveGitHubUrl('') : undefined; + return deriveGitHubEndpoints(enterpriseUri); + } +} + +class WorkbenchGitHubTokenProvider implements IGitHubTokenProvider { + + readonly onDidChangeToken: Event; + + constructor( + private readonly _authenticationService: IAuthenticationService, + private readonly _defaultAccountService: IDefaultAccountService, + ) { + this.onDidChangeToken = Event.any( + Event.map(Event.filter( + _authenticationService.onDidChangeSessions, + event => event.providerId === _defaultAccountService.getDefaultAccountAuthenticationProvider().id, + ), () => undefined), + Event.map(_defaultAccountService.onDidChangeDefaultAccount, () => undefined), + ); + } + + async getToken(): Promise { + const provider = this._defaultAccountService.getDefaultAccountAuthenticationProvider(); + const defaultAccount = this._defaultAccountService.currentDefaultAccount ?? await this._defaultAccountService.getDefaultAccount(); + const sessions = await this._authenticationService.getSessions(provider.id, [], { silent: true }, true); + const defaultSession = defaultAccount + ? sessions.find(session => session.id === defaultAccount.sessionId) + : undefined; + if (defaultAccount && !defaultSession) { + return undefined; + } + if (defaultSession?.scopes.includes('repo')) { + return defaultSession.accessToken; + } + const repositorySessions = await this._authenticationService.getSessions(provider.id, ['repo'], { + createIfNone: true, + ...(defaultSession ? { account: defaultSession.account } : {}), + }, true); + return repositorySessions.find(session => !defaultSession || session.account.id === defaultSession.account.id)?.accessToken; + } +} + +export class WorkbenchGitHubService extends GitHubService { + + constructor( + @IAuthenticationService authenticationService: IAuthenticationService, + @IDefaultAccountService defaultAccountService: IDefaultAccountService, + @ILogService logService: ILogService, + ) { + super({ + endpoint: new WorkbenchGitHubEndpointProvider(defaultAccountService), + tokenProvider: new WorkbenchGitHubTokenProvider(authenticationService, defaultAccountService), + }, logService); + } +} + +registerSingleton(IGitHubService, WorkbenchGitHubService, InstantiationType.Delayed); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatRichLink.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatRichLink.fixture.ts index 9761f043268..05af1df575d 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatRichLink.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatRichLink.fixture.ts @@ -5,12 +5,19 @@ import { constObservable } from '../../../../../base/common/observable.js'; import { mock } from '../../../../../base/test/common/mock.js'; +import { buildAgentSessionLinkPresentation } from '../../../../../platform/agentHost/common/openSessionLink.js'; import { ILinkPresentation, ILinkPresentationRule, ILinkPresentationService, ILinkPresentationWatcher } from '../../../../../platform/dataChannel/common/dataChannel.js'; -import { ChatRichLink, IChatLinkPresentation } from '../../../../contrib/chat/browser/widget/chatContentParts/chatRichLink.js'; +import { ChatRichLink } from '../../../../contrib/chat/browser/widget/chatContentParts/chatRichLink.js'; import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; import { renderChatWidget } from './chatWidget.fixture.js'; +import { buildGitCommitPresentation, buildGitHubFolderPresentation, buildGitHubIssuePresentation, buildGitHubPullRequestPresentation, buildGitHubRepositoryPresentation, buildLoadingPresentationFromCached } from './linkPresentationBuilders.js'; -function renderRichLinks(context: ComponentFixtureContext, presentations: readonly IChatLinkPresentation[]): void { +interface RichLinkFixtureData { + readonly authoredLabel: string; + readonly presentation: ILinkPresentation; +} + +function renderRichLinks(context: ComponentFixtureContext, links: readonly RichLinkFixtureData[]): void { context.container.classList.add('monaco-workbench', 'chat-rich-link-fixture'); context.container.style.display = 'grid'; context.container.style.gridTemplateColumns = 'repeat(2, max-content)'; @@ -21,11 +28,11 @@ function renderRichLinks(context: ComponentFixtureContext, presentations: readon context.container.style.minHeight = '180px'; context.container.style.backgroundColor = 'var(--vscode-editor-background)'; - for (const presentation of presentations) { + for (const { authoredLabel: label, presentation } of links) { const anchor = context.container.ownerDocument.createElement('a'); anchor.href = '#'; const authoredLabel = context.container.ownerDocument.createElement('span'); - authoredLabel.textContent = presentation.title ?? presentation.reference ?? presentation.kind; + authoredLabel.textContent = label; const richLink = context.disposableStore.add(ChatRichLink.mount(anchor, authoredLabel)); richLink.update(presentation); context.container.appendChild(anchor); @@ -35,7 +42,7 @@ function renderRichLinks(context: ComponentFixtureContext, presentations: readon function createLinkPresentationService(presentation: ILinkPresentation): ILinkPresentationService { return new class extends mock() { override getLinkPresentationRule(): ILinkPresentationRule { - return { id: 'fixture', uriPattern: /.*/, initialKind: 'resource' }; + return { id: 'fixture', uriPattern: /.*/, kind: presentation.kind }; } override createLinkPresentationWatcher(): ILinkPresentationWatcher { return { @@ -46,15 +53,14 @@ function createLinkPresentationService(presentation: ILinkPresentation): ILinkPr }(); } -const githubPullRequestPresentation: ILinkPresentation = { - kind: 'pullRequest', +const githubPullRequestPresentation = buildGitHubPullRequestPresentation({ + owner: 'hediet', + repository: 'demo-json-schema-validator', + number: 7, title: 'Validate schemas through declared meta-schemas', - reference: '#7', status: { kind: 'draft', label: 'Draft' }, - secondaryStatus: { kind: 'success', label: 'Checks passed' }, - tooltip: 'hediet/demo-json-schema-validator#7 · Draft · Checks passed', - ariaLabel: 'Pull request hediet slash demo-json-schema-validator number 7, Draft, Checks passed: Validate schemas through declared meta-schemas', -}; + checksStatus: { kind: 'success', label: 'Checks passed' }, +}); export default defineThemedFixtureGroup({ path: 'chat/' }, { inChat: defineComponentFixture({ @@ -62,12 +68,7 @@ export default defineThemedFixtureGroup({ path: 'chat/' }, { width: 720, height: 320, inputVisible: false, - linkPresentationService: createLinkPresentationService({ - kind: 'session', - title: 'Implement rich links', - detail: 'Agent session', - status: { kind: 'pending', label: 'Working' }, - }), + linkPresentationService: createLinkPresentationService(buildAgentSessionLinkPresentation('Implement rich links', 'Agent session', 'inProgress')), messages: [{ user: 'Continue the implementation', assistant: [{ @@ -97,10 +98,7 @@ export default defineThemedFixtureGroup({ path: 'chat/' }, { width: 720, height: 320, inputVisible: false, - linkPresentationService: createLinkPresentationService({ - ...githubPullRequestPresentation, - isLoading: true, - }), + linkPresentationService: createLinkPresentationService(buildLoadingPresentationFromCached(githubPullRequestPresentation)), messages: [{ user: 'What is open?', assistant: [{ @@ -112,21 +110,62 @@ export default defineThemedFixtureGroup({ path: 'chat/' }, { }), sessionStates: defineComponentFixture({ render: context => renderRichLinks(context, [ - { kind: 'session', title: 'Preparing implementation', status: { kind: 'pending', label: 'Loading' } }, - { kind: 'session', title: 'Implement rich links', status: { kind: 'pending', label: 'Working' } }, - { kind: 'session', title: 'Review architecture', status: { kind: 'warning', label: 'Needs input' } }, - { kind: 'session', title: 'Update fixtures', status: { kind: 'success', label: 'Completed' } }, - { kind: 'session', title: 'Run validation', status: { kind: 'error', label: 'Error' } }, + { authoredLabel: 'Preparing implementation', presentation: buildAgentSessionLinkPresentation('Preparing implementation', undefined, 'untitled') }, + { authoredLabel: 'Implement rich links', presentation: buildAgentSessionLinkPresentation('Implement rich links', undefined, 'inProgress') }, + { authoredLabel: 'Review architecture', presentation: buildAgentSessionLinkPresentation('Review architecture', undefined, 'needsInput') }, + { authoredLabel: 'Update fixtures', presentation: buildAgentSessionLinkPresentation('Update fixtures', undefined, 'completed') }, + { authoredLabel: 'Run validation', presentation: buildAgentSessionLinkPresentation('Run validation', undefined, 'error') }, ]), }), presentationKinds: defineComponentFixture({ render: context => renderRichLinks(context, [ - { kind: 'issue', title: 'Rich links in chat', reference: '#330678', status: { kind: 'open', label: 'Open' } }, - { kind: 'pullRequest', title: 'Render rich links', reference: '#330678', status: { kind: 'merged', label: 'Merged' }, secondaryStatus: { kind: 'success', label: 'Checks passed' } }, - { kind: 'commit', title: 'Refine rich links', reference: '4d291e3', changes: { insertions: 42, deletions: 7 } }, - { kind: 'file', title: 'chatRichLink.ts', detail: 'src/vs/workbench/contrib/chat' }, - { kind: 'folder', title: 'componentFixtures', detail: 'src/vs/workbench/test/browser' }, - { kind: 'repository', title: 'microsoft/vscode', detail: 'main' }, + { + authoredLabel: '#330678', + presentation: buildGitHubIssuePresentation({ + owner: 'microsoft', + repository: 'vscode', + number: 330678, + title: 'Rich links in chat', + status: { kind: 'open', label: 'Open' }, + }), + }, + { + authoredLabel: '#330925', + presentation: buildGitHubPullRequestPresentation({ + owner: 'microsoft', + repository: 'vscode', + number: 330925, + title: 'Render rich links', + status: { kind: 'draft', label: 'Draft' }, + checksStatus: { kind: 'success', label: 'Checks passed' }, + }), + }, + { + authoredLabel: '4d291e3', + presentation: buildGitCommitPresentation({ + hash: '4d291e3123456789', + message: 'Refine rich links', + shortStat: { insertions: 42, deletions: 7 }, + }), + }, + { + authoredLabel: 'componentFixtures', + presentation: buildGitHubFolderPresentation({ + owner: 'microsoft', + repository: 'vscode', + path: 'src/vs/workbench/test/browser/componentFixtures', + href: 'https://github.com/microsoft/vscode/tree/main/src/vs/workbench/test/browser/componentFixtures', + }), + }, + { + authoredLabel: 'microsoft/vscode', + presentation: buildGitHubRepositoryPresentation({ + owner: 'microsoft', + repository: 'vscode', + language: 'TypeScript', + stars: 177_000, + }), + }, ]), }), }); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/linkPresentationBuilders.ts b/src/vs/workbench/test/browser/componentFixtures/chat/linkPresentationBuilders.ts new file mode 100644 index 00000000000..e62bd2d6896 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/chat/linkPresentationBuilders.ts @@ -0,0 +1,290 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export type LinkPresentationKind = + | 'resource' + | 'issue' + | 'pullRequest' + | 'commit' + | 'file' + | 'folder' + | 'session' + | 'repository' + | 'branch'; + +export type LinkPresentationStatusKind = + | 'neutral' + | 'pending' + | 'success' + | 'warning' + | 'error' + | 'open' + | 'closed' + | 'merged' + | 'draft' + | 'notPlanned'; + +export interface LinkPresentationStatus { + readonly kind: LinkPresentationStatusKind; + readonly label: string; +} + +export interface LinkPresentation { + readonly kind: LinkPresentationKind; + readonly title?: string; + readonly detail?: string; + readonly reference?: string; + readonly status?: LinkPresentationStatus; + readonly secondaryStatus?: LinkPresentationStatus; + readonly tooltip?: string; + readonly ariaLabel?: string; + readonly isLoading?: boolean; +} + +export type GitHubIssueStatus = LinkPresentationStatus & { + readonly kind: 'open' | 'closed' | 'notPlanned'; +}; + +export type GitHubPullRequestStatus = LinkPresentationStatus & { + readonly kind: 'open' | 'closed' | 'merged' | 'draft'; +}; + +export type GitHubChecksStatus = LinkPresentationStatus & { + readonly kind: 'pending' | 'success' | 'error'; +}; + +interface GitHubResourcePresentationData { + readonly owner: string; + readonly repository: string; +} + +export interface GitHubIssuePresentationData extends GitHubResourcePresentationData { + readonly number: number; + readonly title: string; + readonly status: GitHubIssueStatus; +} + +export function buildGitHubIssuePresentation(data: GitHubIssuePresentationData): LinkPresentation { + return { + kind: 'issue', + title: data.title, + reference: `#${data.number}`, + status: data.status, + tooltip: `${data.owner}/${data.repository}#${data.number} · ${data.status.label}`, + ariaLabel: `Issue ${data.owner} slash ${data.repository} number ${data.number}, ${data.status.label}: ${data.title}`, + }; +} + +export interface GitHubPullRequestPresentationData extends GitHubResourcePresentationData { + readonly number: number; + readonly title: string; + readonly status: GitHubPullRequestStatus; + readonly checksStatus?: GitHubChecksStatus; +} + +export function buildGitHubPullRequestPresentation(data: GitHubPullRequestPresentationData): LinkPresentation { + const checksStatus = data.status.kind === 'open' || data.status.kind === 'draft' ? data.checksStatus : undefined; + return { + kind: 'pullRequest', + title: data.title, + reference: `#${data.number}`, + status: data.status, + ...(checksStatus ? { secondaryStatus: checksStatus } : {}), + tooltip: [`${data.owner}/${data.repository}#${data.number}`, data.status.label, checksStatus?.label].filter(Boolean).join(' · '), + ariaLabel: `Pull request ${data.owner} slash ${data.repository} number ${data.number}, ${data.status.label}${checksStatus ? `, ${checksStatus.label}` : ''}: ${data.title}`, + }; +} + +export interface GitHubRepositoryPresentationData extends GitHubResourcePresentationData { + readonly language?: string; + readonly stars?: number; +} + +export function buildGitHubRepositoryPresentation(data: GitHubRepositoryPresentationData): LinkPresentation { + const details = [ + data.language, + data.stars === undefined ? undefined : `${formatCount(data.stars)} stars`, + ].filter((value): value is string => !!value); + return { + kind: 'repository', + ...(details.length ? { detail: details.join(' · ') } : {}), + tooltip: `${data.owner}/${data.repository}`, + ariaLabel: `GitHub repository ${data.owner} slash ${data.repository}`, + }; +} + +export interface GitHubFolderPresentationData extends GitHubResourcePresentationData { + readonly path: string; + readonly href: string; +} + +export function buildGitHubFolderPresentation(data: GitHubFolderPresentationData): LinkPresentation { + return { + kind: 'folder', + detail: `${data.owner}/${data.repository} · ${data.path}`, + tooltip: data.href, + ariaLabel: `Folder ${data.path} in ${data.owner} slash ${data.repository}`, + }; +} + +export interface GitHubBranchPresentationData extends GitHubResourcePresentationData { + readonly branch: string; + readonly sha: string; +} + +export function buildGitHubBranchPresentation(data: GitHubBranchPresentationData): LinkPresentation { + return { + kind: 'branch', + detail: data.sha.slice(0, 7), + tooltip: `${data.owner}/${data.repository} · ${data.branch}`, + ariaLabel: `Branch ${data.branch} in ${data.owner} slash ${data.repository}`, + }; +} + +export interface GitHubFilePresentationData extends GitHubResourcePresentationData { + readonly path: string; + readonly href: string; +} + +export function buildGitHubFilePresentation(data: GitHubFilePresentationData): LinkPresentation { + return { + kind: 'file', + detail: `${data.owner}/${data.repository} · ${data.path}`, + tooltip: data.href, + ariaLabel: `File ${data.path} in ${data.owner} slash ${data.repository}`, + }; +} + +export interface GitHubLookupFailurePresentationData { + readonly kind: 'resource' | 'issue' | 'pullRequest' | 'file' | 'repository'; + readonly label: string; + readonly detail: string; + readonly errorMessage?: string; +} + +export function buildGitHubLookupFailurePresentation(data: GitHubLookupFailurePresentationData): LinkPresentation { + return { + kind: data.kind, + status: { kind: 'error', label: data.label }, + tooltip: `${data.detail} ${data.errorMessage ?? ''}`.trim(), + ariaLabel: `GitHub ${data.kind} lookup failed: ${data.label}`, + }; +} + +export interface GitCommitPresentationData { + readonly hash: string; + readonly message: string; + readonly shortStat?: { + readonly insertions: number; + readonly deletions: number; + }; +} + +export function buildGitCommitPresentation(commit: GitCommitPresentationData): LinkPresentation { + const title = commit.message.split(/\r?\n/, 1)[0]; + const insertions = commit.shortStat?.insertions ?? 0; + const deletions = commit.shortStat?.deletions ?? 0; + const shortHash = commit.hash.slice(0, 7); + return { + kind: 'commit', + detail: title, + tooltip: `${shortHash} · ${title} · ${insertions} insertions, ${deletions} deletions`, + ariaLabel: `Commit ${shortHash}, ${insertions} insertions and ${deletions} deletions: ${title}`, + }; +} + +export function buildGitCommitLookupFailurePresentation(shortHash: string, tooltip: string): LinkPresentation { + return { + kind: 'commit', + status: { kind: 'error', label: 'Not available' }, + tooltip, + ariaLabel: `Git commit ${shortHash} could not be resolved`, + }; +} + +export interface WorkspaceRepositoryPresentationData { + readonly label: string; + readonly href: string; + readonly branch?: string; + readonly changeCount: number; +} + +export function buildWorkspaceRepositoryPresentation(data: WorkspaceRepositoryPresentationData): LinkPresentation { + const detail = [data.branch, data.changeCount ? `${data.changeCount} changes` : 'clean'].filter((value): value is string => !!value).join(' · '); + return { + kind: 'repository', + ...(detail ? { detail } : {}), + status: data.branch ? { kind: data.changeCount ? 'warning' : 'success', label: data.branch } : undefined, + tooltip: data.href, + ariaLabel: `Local repository ${data.label}${data.branch ? ` on branch ${data.branch}` : ''}, ${data.changeCount ? `${data.changeCount} changes` : 'clean'}`, + }; +} + +export interface WorkspaceResourcePresentationData { + readonly kind: 'file' | 'folder'; + readonly label: string; + readonly href: string; + readonly branch?: string; + readonly modified: boolean; +} + +export function buildWorkspaceResourcePresentation(data: WorkspaceResourcePresentationData): LinkPresentation { + const details = [ + compactParent(data.label), + data.branch, + data.modified ? 'modified' : undefined, + ].filter((value): value is string => !!value); + return { + kind: data.kind, + ...(details.length ? { detail: details.join(' · ') } : {}), + tooltip: data.href, + ariaLabel: `${data.kind === 'folder' ? 'Folder' : 'File'} ${data.label}`, + }; +} + +export function buildLoadingLinkPresentation(kind: LinkPresentation['kind'], label = 'Loading'): LinkPresentation { + return { + kind, + status: { kind: 'pending', label }, + }; +} + +export function buildWorkspaceLookupFailurePresentation( + kind: 'file' | 'folder', + label: string, + tooltip: string, + ariaLabel: string, +): LinkPresentation { + return { + kind, + status: { kind: 'error', label }, + tooltip, + ariaLabel, + }; +} + +export function buildLoadingPresentationFromCached(presentation: LinkPresentation): LinkPresentation { + return { ...presentation, isLoading: true }; +} + +function relativeParent(value: string): string | undefined { + const separator = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')); + return separator > 0 ? value.slice(0, separator) : undefined; +} + +function compactParent(value: string): string | undefined { + const parent = relativeParent(value); + if (!parent) { + return undefined; + } + if (!/^(?:[a-z]:[\\/]|[\\/])/i.test(parent)) { + return parent; + } + return parent.split(/[\\/]+/).filter(Boolean).slice(-4).join('/'); +} + +function formatCount(value: number): string { + return value >= 1000 ? `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k` : String(value); +} diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index cca4ad4de67..d325fcfb949 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -142,6 +142,7 @@ import './services/userAttention/browser/userAttentionBrowser.js'; import './services/editor/browser/editorPaneService.js'; import './services/editor/common/customEditorLabelService.js'; import './services/dataChannel/browser/dataChannelService.js'; +import './services/github/browser/githubService.js'; import './services/inlineCompletions/common/inlineCompletionsUnification.js'; import './services/chat/common/chatEntitlementService.js'; import './services/agentHost/common/agentHostResourceService.js'; @@ -279,6 +280,7 @@ import './contrib/sash/browser/sash.contribution.js'; // Git import './contrib/git/browser/git.contributions.js'; +import './contrib/github/browser/githubLinkPresentation.contribution.js'; // SCM import './contrib/scm/browser/scm.contribution.js'; diff --git a/src/vscode-dts/vscode.proposed.linkPresentation.d.ts b/src/vscode-dts/vscode.proposed.linkPresentation.d.ts index b9f0f489329..859cc612991 100644 --- a/src/vscode-dts/vscode.proposed.linkPresentation.d.ts +++ b/src/vscode-dts/vscode.proposed.linkPresentation.d.ts @@ -80,9 +80,9 @@ declare module 'vscode' { readonly uriPattern: RegExp; /** - * The semantic kind used for an initial presentation before provider data is available. + * The semantic kind produced by this provider. */ - readonly initialKind: LinkPresentationKind; + readonly kind: LinkPresentationKind; } /** From dc51f4a3aed1be6e2debd16e320705aadc241d99 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 25 Aug 2026 19:28:24 -0700 Subject: [PATCH 032/116] chat: require confirmation for .mcp.json and .npmrc edits (#332639) * chat: require confirmation for .mcp.json edits Updates edit approval patterns so edits to `.mcp.json` require user confirmation in both edit execution paths. - Adds `.mcp.json` to the standard chat edit confirmation patterns. - Adds `.mcp.json` to the agent host edit confirmation patterns. - Extends focused tests for both edit execution paths. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: test .mcp.json path casing Adds `.mcp.json` to the existing non-canonical casing coverage for protected edit paths. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: always confirm .npmrc edits Treat .npmrc files as non-overridable protected edit targets in both chat approval paths, with root and nested-path coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extension/tools/node/editFileToolUtils.tsx | 2 ++ .../tools/node/test/editFileToolUtils.spec.ts | 18 ++++++++++++++---- .../test/node/sessionPermissions.test.ts | 8 ++++++-- src/vs/platform/chat/common/chatSettings.ts | 2 ++ 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx b/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx index da68766d1fc..5d9e5cfb9d5 100644 --- a/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx +++ b/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx @@ -709,6 +709,8 @@ export async function applyEdit( } const ALWAYS_CHECKED_EDIT_PATTERNS: Readonly> = { + '**/.mcp.json': false, + '**/.npmrc': false, '**/.vscode/*.json': false, // Markdown files in these folders are loaded as custom agents; their // frontmatter can declare a `hooks:` block that runs shell commands during diff --git a/extensions/copilot/src/extension/tools/node/test/editFileToolUtils.spec.ts b/extensions/copilot/src/extension/tools/node/test/editFileToolUtils.spec.ts index ab174f39cf8..7f19fab06f0 100644 --- a/extensions/copilot/src/extension/tools/node/test/editFileToolUtils.spec.ts +++ b/extensions/copilot/src/extension/tools/node/test/editFileToolUtils.spec.ts @@ -748,14 +748,24 @@ describe('makeUriConfirmationChecker', async () => { expect(result).toBe(ConfirmationCheckResult.Sensitive); // Sensitive }); - test('always checks .vscode/*.json files', async () => { + test('always checks sensitive configuration files', async () => { const workspaceFolder = URI.file('/workspace'); workspaceService = new TestWorkspaceService([workspaceFolder], []); + await configService.setNonExtensionConfig('chat.tools.edits.autoApprove', { + '**/.mcp.json': true, + '**/.npmrc': true, + }); + const checker = makeUriConfirmationChecker(configService, workspaceService.getWorkspaceFolder.bind(workspaceService), customInstructionsService); - const settingsFile = URI.file('/workspace/.vscode/settings.json'); - const result = await checker(settingsFile); - expect(result).toBe(ConfirmationCheckResult.Sensitive); // Sensitive - always requires confirmation + const files = [ + URI.file('/workspace/.mcp.json'), + URI.file('/workspace/.npmrc'), + URI.file('/workspace/packages/nested/.npmrc'), + URI.file('/workspace/.vscode/settings.json'), + ]; + const results = await Promise.all(files.map(file => checker(file))); + expect(results).toEqual(files.map(() => ConfirmationCheckResult.Sensitive)); }); test('pattern precedence - later patterns override earlier ones', async () => { diff --git a/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts b/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts index 3ee40051de3..b7dc09d9fea 100644 --- a/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts @@ -172,6 +172,7 @@ suite('SessionPermissionManager', () => { test('requires confirmation for protected files inside the working directory', async () => { const files = [ '.env', + '.mcp.json', 'package.json', 'Cargo.toml', 'build.gradle', @@ -191,7 +192,7 @@ suite('SessionPermissionManager', () => { if (!isLinux) { test('requires confirmation for protected files with non-canonical casing', async () => { - const files = ['.ENV', 'Package.json', join('.GIT', 'config'), join('.VSCODE', 'settings.json')]; + const files = ['.ENV', '.MCP.JSON', 'Package.json', join('.GIT', 'config'), join('.VSCODE', 'settings.json')]; const results = await Promise.all(files.map(file => permissions.getAutoApproval(writeEvent(join(workDir, file)), sessionUri))); assert.deepStrictEqual(results, files.map(() => undefined)); }); @@ -216,6 +217,7 @@ suite('SessionPermissionManager', () => { '**/*': false, '**/*.ts': true, '**/.github/hooks/**': true, + '**/.npmrc': true, }, }); @@ -223,7 +225,9 @@ suite('SessionPermissionManager', () => { await permissions.getAutoApproval(writeEvent(join(workDir, 'src', 'app.ts')), sessionUri), await permissions.getAutoApproval(writeEvent(join(workDir, 'README.md')), sessionUri), await permissions.getAutoApproval(writeEvent(join(workDir, '.github', 'hooks', 'pre-tool.json')), sessionUri), - ], [ToolCallConfirmationReason.NotNeeded, undefined, undefined]); + await permissions.getAutoApproval(writeEvent(join(workDir, '.npmrc')), sessionUri), + await permissions.getAutoApproval(writeEvent(join(workDir, 'packages', 'nested', '.npmrc')), sessionUri), + ], [ToolCallConfirmationReason.NotNeeded, undefined, undefined, undefined, undefined]); }); test('merges configured edit auto-approve patterns with defaults', () => { diff --git a/src/vs/platform/chat/common/chatSettings.ts b/src/vs/platform/chat/common/chatSettings.ts index 6ed932c63b0..686f18c1408 100644 --- a/src/vs/platform/chat/common/chatSettings.ts +++ b/src/vs/platform/chat/common/chatSettings.ts @@ -19,6 +19,8 @@ export const enum ChatExternalSessionsMode { /** Edit paths whose executable side effects require confirmation regardless of user configuration. */ export const ALWAYS_CHECKED_EDIT_PATTERNS: ChatEditAutoApprovePatterns = { + '**/.mcp.json': false, + '**/.npmrc': false, '**/.vscode/*.json': false, '**/.github/agents/**': false, '**/.github/hooks/**': false, From b40cda889f858253adea52245b084f37587f5b02 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 25 Aug 2026 19:30:37 -0700 Subject: [PATCH 033/116] agent host: pass bridge token through environment (#332657) * agent host: pass bridge token through environment Updates the server launch path to carry the agent host bridge token separately from parsed server arguments. - Passes the bridge token through the process environment. - Threads the token through server startup as dedicated configuration. - Redacts connection tokens from argument and endpoint logs. - Adds coverage for command construction and log output. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agent host: log bridge endpoint on connect Defers bridge endpoint logging until the renderer starts the upstream connection. - Creates static and deferred upstream connections on the first connect call. - Keeps listener registration free of connection log output. - Updates the endpoint logging test to cover the connection boundary. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/src/tunnels/code_server.rs | 69 +++++++++++++++++-- .../vscode-test-resolver/src/extension.ts | 3 +- src/vs/server/node/agentHostChannel.ts | 11 +-- .../node/remoteExtensionHostAgentCli.ts | 4 +- .../node/remoteExtensionHostAgentServer.ts | 4 +- src/vs/server/node/server.main.ts | 6 +- .../server/node/serverEnvironmentService.ts | 16 +++++ src/vs/server/node/serverServices.ts | 11 +-- .../server/test/node/agentHostChannel.test.ts | 29 ++++++++ .../test/node/serverConnectionToken.test.ts | 30 +++++++- 10 files changed, 160 insertions(+), 23 deletions(-) diff --git a/cli/src/tunnels/code_server.rs b/cli/src/tunnels/code_server.rs index 18466efe027..cb9db9edaa1 100644 --- a/cli/src/tunnels/code_server.rs +++ b/cli/src/tunnels/code_server.rs @@ -43,6 +43,7 @@ static LISTENING_PORT_RE: LazyLock = LazyLock::new(|| Regex::new(r"Extension host agent listening on (.+)").unwrap()); static WEB_UI_RE: LazyLock = LazyLock::new(|| Regex::new(r"Web UI available at (.+)").unwrap()); +const AGENT_HOST_BRIDGE_CONNECTION_TOKEN_ENV: &str = "VSCODE_AGENT_HOST_BRIDGE_CONNECTION_TOKEN"; #[derive(Clone, Debug, Default)] pub struct CodeServerArgs { @@ -172,12 +173,18 @@ impl CodeServerArgs { if let Some(host) = &self.agent_host_bridge_host { args.push(format!("--agent-host-bridge-host={host}")); } - if let Some(token) = &self.agent_host_bridge_connection_token { - args.push(format!("--agent-host-bridge-connection-token={token}")); - } } args } + + fn apply_to_command(&self, command: &mut Command) { + command.args(self.command_arguments()); + if self.agent_host_bridge_port.is_some() { + if let Some(token) = &self.agent_host_bridge_connection_token { + command.env(AGENT_HOST_BRIDGE_CONNECTION_TOKEN_ENV, token); + } + } + } } /// Base server params that can be `resolve()`d to a `ResolvedServerParams`. @@ -630,7 +637,11 @@ impl<'a> ServerBuilder<'a> { async fn spawn_server_process(&self, mut cmd: Command) -> Result { info!(self.logger, "Starting server..."); - debug!(self.logger, "Starting server with command... {:?}", cmd); + debug!( + self.logger, + "Starting server process: {:?}", + cmd.as_std().get_program() + ); // On Windows spawning a code-server binary will run cmd.exe /c C:\path\to\code-server.cmd... // This spawns a cmd.exe window for the user, which if they close will kill the code-server process @@ -688,8 +699,10 @@ impl<'a> ServerBuilder<'a> { fn get_base_command(&self) -> Command { let mut cmd = new_script_command(&self.server_paths.executable); - cmd.stdin(std::process::Stdio::null()) - .args(self.server_params.code_server_args.command_arguments()); + cmd.stdin(std::process::Stdio::null()); + self.server_params + .code_server_args + .apply_to_command(&mut cmd); cmd } } @@ -959,3 +972,47 @@ async fn get_should_use_breakaway_from_job() -> bool { cmd.args(["/C", "echo ok"]).output().await.is_ok() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn agent_host_bridge_connection_token_is_only_in_command_environment() { + let args = CodeServerArgs { + agent_host_bridge_host: Some("127.0.0.1".to_string()), + agent_host_bridge_port: Some(9000), + agent_host_bridge_connection_token: Some("secret-token".to_string()), + ..Default::default() + }; + let mut command = Command::new("code-server"); + args.apply_to_command(&mut command); + let command = command.as_std(); + + assert_eq!( + ( + command + .get_args() + .map(|argument| argument.to_string_lossy().into_owned()) + .collect::>(), + command + .get_envs() + .map(|(name, value)| ( + name.to_string_lossy().into_owned(), + value.map(|value| value.to_string_lossy().into_owned()) + )) + .collect::>(), + ), + ( + vec![ + "--agent-host-bridge-port=9000".to_string(), + "--agent-host-bridge-host=127.0.0.1".to_string(), + ], + vec![( + AGENT_HOST_BRIDGE_CONNECTION_TOKEN_ENV.to_string(), + Some("secret-token".to_string()), + )], + ) + ); + } +} diff --git a/extensions/vscode-test-resolver/src/extension.ts b/extensions/vscode-test-resolver/src/extension.ts index 32a93fe3646..3cc55e003e8 100644 --- a/extensions/vscode-test-resolver/src/extension.ts +++ b/extensions/vscode-test-resolver/src/extension.ts @@ -23,6 +23,7 @@ const enum CharCode { let outputChannel: vscode.OutputChannel; const SLOWED_DOWN_CONNECTION_DELAY = 800; +const agentHostBridgeConnectionTokenEnvironmentVariable = 'VSCODE_AGENT_HOST_BRIDGE_CONNECTION_TOKEN'; function isExpectedSocketCloseError(error: NodeJS.ErrnoException): boolean { return error.code === 'ECONNRESET' || error.code === 'EPIPE' || error.code === 'ECONNABORTED'; @@ -190,7 +191,7 @@ export function activate(context: vscode.ExtensionContext) { } const agentHostBridgeToken = getConfiguration('agentHostBridgeConnectionToken'); if (typeof agentHostBridgeToken === 'string' && agentHostBridgeToken) { - commandArgs.push('--agent-host-bridge-connection-token', agentHostBridgeToken); + env[agentHostBridgeConnectionTokenEnvironmentVariable] = agentHostBridgeToken; } if (!commit) { // dev mode diff --git a/src/vs/server/node/agentHostChannel.ts b/src/vs/server/node/agentHostChannel.ts index 322ed923f35..725475157a5 100644 --- a/src/vs/server/node/agentHostChannel.ts +++ b/src/vs/server/node/agentHostChannel.ts @@ -208,7 +208,6 @@ class WebSocketUpstreamConnection extends Disposable implements IUpstreamConnect const url = this._buildUrl(); const wsOptions = await this._buildWsOptions(); - this._logService.info(`[AgentHostChannel] Opening upstream to ${this._endpoint.socketPath ?? url}`); const socket = new ws.WebSocket(url, wsOptions); this._ws = socket; @@ -361,9 +360,7 @@ export class AgentHostChannel extends Disposable implements IServerCha private _getOrCreate(ctx: TContext): IUpstreamConnection { let conn = this._perCtx.get(ctx); if (!conn) { - conn = typeof this._endpoint === 'function' - ? new LazyUpstreamConnection(() => this._resolveEndpoint(), this._upstreamFactory, this._logService) - : this._upstreamFactory(this._endpoint); + conn = new LazyUpstreamConnection(() => this._resolveEndpoint(), endpoint => this._createUpstream(endpoint), this._logService); this._perCtx.set(ctx, conn); // If the upstream closes on its own (e.g. agent host restart or // connection drop), evict it from the cache so the next @@ -379,6 +376,12 @@ export class AgentHostChannel extends Disposable implements IServerCha return conn; } + private _createUpstream(endpoint: IAgentHostUpstreamEndpoint): IUpstreamConnection { + const logTarget = endpoint.socketPath ?? `${endpoint.host ?? 'localhost'}:${endpoint.port ?? '0'}`; + this._logService.info(`[AgentHostChannel] Opening upstream to ${logTarget}`); + return this._upstreamFactory(endpoint); + } + private async _resolveEndpoint(): Promise { const endpoint = this._endpoint; if (typeof endpoint !== 'function') { diff --git a/src/vs/server/node/remoteExtensionHostAgentCli.ts b/src/vs/server/node/remoteExtensionHostAgentCli.ts index 46238862457..f7d10061ad8 100644 --- a/src/vs/server/node/remoteExtensionHostAgentCli.ts +++ b/src/vs/server/node/remoteExtensionHostAgentCli.ts @@ -25,7 +25,7 @@ import { DiskFileSystemProvider } from '../../platform/files/node/diskFileSystem import { Schemas } from '../../base/common/network.js'; import { IFileService } from '../../platform/files/common/files.js'; import { IProductService } from '../../platform/product/common/productService.js'; -import { IServerEnvironmentService, ServerEnvironmentService, ServerParsedArgs } from './serverEnvironmentService.js'; +import { getRedactedServerParsedArgs, IServerEnvironmentService, ServerEnvironmentService, ServerParsedArgs } from './serverEnvironmentService.js'; import { ExtensionManagementCLI } from '../../platform/extensionManagement/common/extensionManagementCLI.js'; import { ILanguagePackService } from '../../platform/languagePacks/common/languagePacks.js'; import { NativeLanguagePackService } from '../../platform/languagePacks/node/languagePacks.js'; @@ -107,7 +107,7 @@ class CliMain extends Disposable { const logService = new LogService(this._register(loggerService.createLogger('remoteCLI', { name: localize('remotecli', "Remote CLI") }))); services.set(ILogService, logService); logService.trace(`Remote configuration data at ${this.remoteDataFolder}`); - logService.trace('process arguments:', this.args); + logService.trace('process arguments:', getRedactedServerParsedArgs(this.args)); // Files const fileService = this._register(new FileService(logService)); diff --git a/src/vs/server/node/remoteExtensionHostAgentServer.ts b/src/vs/server/node/remoteExtensionHostAgentServer.ts index 2fc682eb1cc..fcecdac0978 100644 --- a/src/vs/server/node/remoteExtensionHostAgentServer.ts +++ b/src/vs/server/node/remoteExtensionHostAgentServer.ts @@ -620,7 +620,7 @@ export interface IServerAPI { dispose(): void; } -export async function createServer(address: string | net.AddressInfo | null, args: ServerParsedArgs, REMOTE_DATA_FOLDER: string): Promise { +export async function createServer(address: string | net.AddressInfo | null, args: ServerParsedArgs, REMOTE_DATA_FOLDER: string, agentHostBridgeConnectionToken: string | undefined): Promise { const connectionToken = await determineServerConnectionToken(args); if (connectionToken instanceof ServerConnectionTokenParseError) { @@ -661,7 +661,7 @@ export async function createServer(address: string | net.AddressInfo | null, arg }); const disposables = new DisposableStore(); - const { socketServer, instantiationService } = await setupServerServices(connectionToken, args, REMOTE_DATA_FOLDER, disposables); + const { socketServer, instantiationService } = await setupServerServices(connectionToken, args, REMOTE_DATA_FOLDER, agentHostBridgeConnectionToken, disposables); // Set the unexpected error handler after the services have been initialized, to avoid having // the telemetry service overwrite our handler diff --git a/src/vs/server/node/server.main.ts b/src/vs/server/node/server.main.ts index c0ccc85d027..fde803a0435 100644 --- a/src/vs/server/node/server.main.ts +++ b/src/vs/server/node/server.main.ts @@ -12,7 +12,7 @@ import { createServer as doCreateServer, IServerAPI } from './remoteExtensionHos import { parseArgs, ErrorReporter } from '../../platform/environment/node/argv.js'; import { join, dirname } from '../../base/common/path.js'; import { performance } from 'perf_hooks'; -import { serverOptions } from './serverEnvironmentService.js'; +import { agentHostBridgeConnectionTokenEnvironmentVariable, serverOptions } from './serverEnvironmentService.js'; import product from '../../platform/product/common/product.js'; import * as perf from '../../base/common/performance.js'; @@ -35,6 +35,8 @@ const errorReporter: ErrorReporter = { }; const args = parseArgs(process.argv.slice(2), serverOptions, errorReporter); +const agentHostBridgeConnectionToken = process.env[agentHostBridgeConnectionTokenEnvironmentVariable]; +delete process.env[agentHostBridgeConnectionTokenEnvironmentVariable]; const REMOTE_DATA_FOLDER = args['server-data-dir'] || process.env['VSCODE_AGENT_FOLDER'] || join(os.homedir(), product.serverDataFolderName || '.vscode-remote'); const USER_DATA_PATH = join(REMOTE_DATA_FOLDER, 'data'); @@ -67,5 +69,5 @@ export function spawnCli() { * invoked by server-main.js */ export function createServer(address: string | net.AddressInfo | null): Promise { - return doCreateServer(address, args, REMOTE_DATA_FOLDER); + return doCreateServer(address, args, REMOTE_DATA_FOLDER, agentHostBridgeConnectionToken); } diff --git a/src/vs/server/node/serverEnvironmentService.ts b/src/vs/server/node/serverEnvironmentService.ts index 4407fcb6066..93546836002 100644 --- a/src/vs/server/node/serverEnvironmentService.ts +++ b/src/vs/server/node/serverEnvironmentService.ts @@ -15,6 +15,22 @@ import { joinPath } from '../../base/common/resources.js'; import { join } from '../../base/common/path.js'; import { ProtocolConstants } from '../../base/parts/ipc/common/ipc.net.js'; +export const agentHostBridgeConnectionTokenEnvironmentVariable = 'VSCODE_AGENT_HOST_BRIDGE_CONNECTION_TOKEN'; + +/** + * Returns server arguments with connection tokens redacted for logging. + */ +export function getRedactedServerParsedArgs(args: ServerParsedArgs): ServerParsedArgs { + const redactedArgs = { ...args }; + if (typeof redactedArgs['connection-token'] !== 'undefined') { + redactedArgs['connection-token'] = ''; + } + if (typeof redactedArgs['agent-host-bridge-connection-token'] !== 'undefined') { + redactedArgs['agent-host-bridge-connection-token'] = ''; + } + return redactedArgs; +} + export const serverOptions: OptionDescriptions> = { /* ----- server setup ----- */ diff --git a/src/vs/server/node/serverServices.ts b/src/vs/server/node/serverServices.ts index cb9e8cdfaa9..98fba790526 100644 --- a/src/vs/server/node/serverServices.ts +++ b/src/vs/server/node/serverServices.ts @@ -60,7 +60,7 @@ import { IServerTelemetryService, ServerNullTelemetryService, ServerTelemetrySer import { RemoteTerminalChannel } from './remoteTerminalChannel.js'; import { createURITransformer } from '../../base/common/uriTransformer.js'; import { ServerConnectionToken, ServerConnectionTokenType } from './serverConnectionToken.js'; -import { ServerEnvironmentService, ServerParsedArgs } from './serverEnvironmentService.js'; +import { getRedactedServerParsedArgs, ServerEnvironmentService, ServerParsedArgs } from './serverEnvironmentService.js'; import { REMOTE_TERMINAL_CHANNEL_NAME } from '../../workbench/contrib/terminal/common/remote/remoteTerminalChannel.js'; import { REMOTE_FILE_SYSTEM_CHANNEL_NAME } from '../../workbench/services/remote/common/remoteFileSystemProviderClient.js'; import { ExtensionHostStatusService, IExtensionHostStatusService } from './extensionHostStatusService.js'; @@ -109,7 +109,7 @@ import { SandboxHelperService } from '../../platform/sandbox/node/sandboxHelper. const eventPrefix = 'monacoworkbench'; -export async function setupServerServices(connectionToken: ServerConnectionToken, args: ServerParsedArgs, REMOTE_DATA_FOLDER: string, disposables: DisposableStore) { +export async function setupServerServices(connectionToken: ServerConnectionToken, args: ServerParsedArgs, REMOTE_DATA_FOLDER: string, agentHostBridgeConnectionToken: string | undefined, disposables: DisposableStore) { const services = new ServiceCollection(); const socketServer = new SocketServer(); @@ -131,7 +131,7 @@ export async function setupServerServices(connectionToken: ServerConnectionToken disposables.add(logService.onDidChangeLogLevel(logLevel => log(logService, logLevel, `Log level changed to ${LogLevelToString(logService.getLevel())}`))); logService.trace(`Remote configuration data at ${REMOTE_DATA_FOLDER}`); - logService.trace('process arguments:', environmentService.args); + logService.trace('process arguments:', getRedactedServerParsedArgs(environmentService.args)); if (Array.isArray(productService.serverGreeting)) { logService.info(`\n\n${productService.serverGreeting.join('\n')}\n\n`); } @@ -292,6 +292,7 @@ export async function setupServerServices(connectionToken: ServerConnectionToken const bridgePath = args['agent-host-bridge-path'] ?? spawnPath; const bridgeHost = args['agent-host-bridge-host'] ?? args.host ?? 'localhost'; const bridgeToken = args['agent-host-bridge-connection-token'] + ?? agentHostBridgeConnectionToken ?? ((bridgePort || bridgePath) && connectionToken.type === ServerConnectionTokenType.Mandatory ? connectionToken.value : undefined); @@ -312,11 +313,11 @@ export async function setupServerServices(connectionToken: ServerConnectionToken socketServer.registerChannel(AgentHostIpcChannels.RemoteProxy, new UnavailableAgentHostChannel()); logService.info(`[AgentHostChannel] Registered unavailable IPC channel '${AgentHostIpcChannels.RemoteProxy}': no --agent-host-bridge-port / --agent-host-bridge-path set.`); } - } else if (args['agent-host-bridge-port'] || args['agent-host-bridge-path'] || args['agent-host-bridge-host'] || args['agent-host-bridge-connection-token']) { + } else if (args['agent-host-bridge-port'] || args['agent-host-bridge-path'] || args['agent-host-bridge-host'] || args['agent-host-bridge-connection-token'] || agentHostBridgeConnectionToken) { const bridgePort = args['agent-host-bridge-port']; const bridgePath = args['agent-host-bridge-path']; const bridgeHost = args['agent-host-bridge-host'] ?? args.host ?? 'localhost'; - const bridgeToken = args['agent-host-bridge-connection-token']; + const bridgeToken = args['agent-host-bridge-connection-token'] ?? agentHostBridgeConnectionToken; if (bridgePort || bridgePath) { const agentHostBridge = disposables.add(new AgentHostChannel( socketServer, diff --git a/src/vs/server/test/node/agentHostChannel.test.ts b/src/vs/server/test/node/agentHostChannel.test.ts index 755f0f35e28..905ef112860 100644 --- a/src/vs/server/test/node/agentHostChannel.test.ts +++ b/src/vs/server/test/node/agentHostChannel.test.ts @@ -11,6 +11,14 @@ import type { Client, IPCServer } from '../../../base/parts/ipc/common/ipc.js'; import { NullLogService } from '../../../platform/log/common/log.js'; import { AgentHostChannel, IAgentHostUpstreamEndpoint, IUpstreamConnection, UnavailableAgentHostChannel } from '../../node/agentHostChannel.js'; +class TestLogService extends NullLogService { + readonly infos: string[] = []; + + override info(message: string, ...args: unknown[]): void { + this.infos.push([message, ...args].join(' ')); + } +} + class FakeUpstream extends Disposable implements IUpstreamConnection { private readonly _onFrame = this._register(new Emitter()); readonly onFrame: Event = this._onFrame.event; @@ -148,6 +156,27 @@ suite('AgentHostChannel', () => { assert.strictEqual(resolveCount, 1); }); + test('does not log the upstream connection token', async () => { + const ipc = ds.add(new FakeIPCServer()); + const logService = new TestLogService(); + const channel = ds.add(new AgentHostChannel( + ipc as unknown as IPCServer, + { host: 'localhost', port: '12345', connectionToken: 'secret-token' }, + logService, + () => ds.add(new FakeUpstream()), + )); + + channel.listen('renderer', 'frame'); + assert.deepStrictEqual(logService.infos, []); + + await channel.call('renderer', 'connect'); + + assert.deepStrictEqual(logService.infos, [ + '[AgentHostChannel] Renderer ctx=renderer requested connect to upstream', + '[AgentHostChannel] Opening upstream to localhost:12345', + ]); + }); + test('shares deferred endpoint resolution between renderer contexts', async () => { const ipc = ds.add(new FakeIPCServer()); let resolveCount = 0; diff --git a/src/vs/server/test/node/serverConnectionToken.test.ts b/src/vs/server/test/node/serverConnectionToken.test.ts index e9affb736ea..50c199c2412 100644 --- a/src/vs/server/test/node/serverConnectionToken.test.ts +++ b/src/vs/server/test/node/serverConnectionToken.test.ts @@ -11,7 +11,7 @@ import { connectionTokenCookieName, connectionTokenQueryName } from '../../../ba import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; import { getRandomTestPath } from '../../../base/test/node/testUtils.js'; import { MandatoryServerConnectionToken, parseServerConnectionToken, requestHasValidConnectionToken, ServerConnectionToken, ServerConnectionTokenParseError, ServerConnectionTokenType } from '../../node/serverConnectionToken.js'; -import { ServerParsedArgs } from '../../node/serverEnvironmentService.js'; +import { getRedactedServerParsedArgs, ServerParsedArgs } from '../../node/serverEnvironmentService.js'; suite('parseServerConnectionToken', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -95,3 +95,31 @@ suite('requestHasValidConnectionToken', () => { assert.strictEqual(requestHasValidConnectionToken(connectionToken, { headers }, new URLSearchParams()), true); }); }); + +suite('getRedactedServerParsedArgs', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('redacts connection tokens without changing the original arguments', () => { + const args = { + 'connection-token': 'server-token', + 'agent-host-bridge-connection-token': 'bridge-token', + 'agent-host-bridge-port': '9000', + } as ServerParsedArgs; + + assert.deepStrictEqual({ + redactedArgs: getRedactedServerParsedArgs(args), + args, + }, { + redactedArgs: { + 'connection-token': '', + 'agent-host-bridge-connection-token': '', + 'agent-host-bridge-port': '9000', + }, + args: { + 'connection-token': 'server-token', + 'agent-host-bridge-connection-token': 'bridge-token', + 'agent-host-bridge-port': '9000', + }, + }); + }); +}); From 07a60371e543757497eed1b24296639811605272 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 25 Aug 2026 19:36:04 -0700 Subject: [PATCH 034/116] agentHost: canonicalize missing paths through all ancestors (#332668) The resource service now walks parent directories until it finds an existing path. It then applies realpath to that ancestor and appends the missing suffix before the grant check. - Walks ancestors in _canonicalize instead of only the immediate parent - Adds a unit test for nested missing paths through a directory symlink - Keeps coverage for new files that stay inside the granted tree (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/agentHostResourceService.ts | 26 ++++++----- .../common/agentHostResourceService.test.ts | 44 ++++++++++++++++++- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/src/vs/workbench/services/agentHost/common/agentHostResourceService.ts b/src/vs/workbench/services/agentHost/common/agentHostResourceService.ts index dbbc2762d56..2f4af94352b 100644 --- a/src/vs/workbench/services/agentHost/common/agentHostResourceService.ts +++ b/src/vs/workbench/services/agentHost/common/agentHostResourceService.ts @@ -363,22 +363,24 @@ export class AgentHostResourceService extends Disposable implements IAgentHostRe * segments and following symlinks so the policy check sees the same * path the OS will actually open. For URIs that don't exist (e.g. a * `resourceWrite` for a new file), realpath the deepest existing - * ancestor and re-append the leaf. + * ancestor and re-append the missing suffix. */ private async _canonicalize(uri: URI): Promise { const normalized = extUri.normalizePath(uri); - const real = await this._fileService.realpath(normalized).catch(() => undefined); - if (real) { - return real; + const suffix: string[] = []; + let current = normalized; + while (true) { + const real = await this._fileService.realpath(current).catch(() => undefined); + if (real) { + return suffix.length ? extUri.joinPath(real, ...suffix) : real; + } + const parent = extUri.dirname(current); + if (extUri.isEqual(parent, current)) { + return normalized; + } + suffix.unshift(extUri.basename(current)); + current = parent; } - const parent = extUri.dirname(normalized); - if (extUri.isEqual(parent, normalized)) { - return normalized; - } - const realParent = await this._fileService.realpath(parent).catch(() => undefined); - return realParent - ? extUri.joinPath(realParent, extUri.basename(normalized)) - : normalized; } private async _isCovered(identity: AgentHostResourceIdentity, canonicalUri: URI, mode: AgentHostPermissionMode): Promise { diff --git a/src/vs/workbench/services/agentHost/test/common/agentHostResourceService.test.ts b/src/vs/workbench/services/agentHost/test/common/agentHostResourceService.test.ts index 47e2e8b1270..f5a491f58c5 100644 --- a/src/vs/workbench/services/agentHost/test/common/agentHostResourceService.test.ts +++ b/src/vs/workbench/services/agentHost/test/common/agentHostResourceService.test.ts @@ -40,8 +40,8 @@ class CapturingConfigurationService extends TestConfigurationService { * unit tests exercise the policy logic without a real filesystem; canonical * form == lexically normalized form. * - * `null` realpath responses simulate non-existent paths to drive the - * `_canonicalize` parent-fallback branch. + * `undefined` realpath responses simulate non-existent paths to drive the + * `_canonicalize` ancestor walk. */ function createStubFileService(opts?: { realpathReturns?: (uri: URI) => URI | undefined; @@ -215,6 +215,46 @@ suite('AgentHostResourceService', () => { ); }); + test('check walks ancestors so nested missing paths through a symlink are denied', async () => { + // `/safe/sym` → `/outside`. Intermediate `/safe/sym/a` and leaf + // `/safe/sym/a/b.txt` do not exist. One missing component under the + // symlink is already denied; two or more used to fall back to the + // lexical workspace path and pass the `/safe` grant. + const fileService = createStubFileService({ + realpathReturns: uri => { + if ( + uri.path === '/safe/sym/a/b.txt' + || uri.path === '/safe/sym/a' + || uri.path === '/safe/sym/new.txt' + || uri.path === '/safe/new/dir/file.txt' + || uri.path === '/safe/new/dir' + || uri.path === '/safe/new' + ) { + return undefined; + } + if (uri.path === '/safe/sym') { + return URI.file('/outside'); + } + return uri; + }, + }); + const { service } = createService({ + 'host': { + [URI.file('/safe').toString()]: AgentHostAccessMode.ReadWrite, + }, + }, fileService); + + assert.deepStrictEqual({ + nestedMissingThroughSymlink: await service.check('host', URI.file('/safe/sym/a/b.txt'), AgentHostPermissionMode.Write), + oneLevelMissingThroughSymlink: await service.check('host', URI.file('/safe/sym/new.txt'), AgentHostPermissionMode.Write), + nestedMissingInsideGrant: await service.check('host', URI.file('/safe/new/dir/file.txt'), AgentHostPermissionMode.Write), + }, { + nestedMissingThroughSymlink: false, + oneLevelMissingThroughSymlink: false, + nestedMissingInsideGrant: true, + }); + }); + test('request resolves immediately when already granted', async () => { const { service } = createService(); disposables.add(service.grantImplicitRead('host', URI.file('/plugins/foo'))); From 39ef71161d3524478d24a733ecd6b9b0f33e8429 Mon Sep 17 00:00:00 2001 From: Robo Date: Wed, 26 Aug 2026 13:37:10 +0900 Subject: [PATCH 035/116] chore: revert to electron@42.8.1 (#332677) * chore: revert to electron@42.8.1 * chore: bump distro --- .npmrc | 4 +- build/checksums/electron.txt | 150 +++++++++++++++++------------------ cgmanifest.json | 6 +- package.json | 2 +- 4 files changed, 81 insertions(+), 81 deletions(-) diff --git a/.npmrc b/.npmrc index 6abf3010ccf..c28bfd82d97 100644 --- a/.npmrc +++ b/.npmrc @@ -1,6 +1,6 @@ disturl="https://electronjs.org/headers" -target="42.9.3" -ms_build_id="15072006" +target="42.8.1" +ms_build_id="14906494" runtime="electron" ignore-scripts=false build_from_source="true" diff --git a/build/checksums/electron.txt b/build/checksums/electron.txt index e09dffbc721..bfb554e136b 100644 --- a/build/checksums/electron.txt +++ b/build/checksums/electron.txt @@ -1,75 +1,75 @@ -68923728c4a777c64a7f4ea90f950c9859d981be341de75e8ee67265ebba28c9 *chromedriver-v42.9.3-darwin-arm64.zip -d37ebde4e474fbb22675c3edab45a5d6e41821c6a3050346401bfa29df9aebf0 *chromedriver-v42.9.3-darwin-x64.zip -fdd4e11784b695bd0152b0aefb1262539ac763ec684cf8a3c50bb20ebcd2b084 *chromedriver-v42.9.3-linux-arm64.zip -3cba937b4ccbe6d0409ed3064c1e126043e96160ac81183140eddfbb69366a07 *chromedriver-v42.9.3-linux-armv7l.zip -9abaadfe6c446613d3623ffb5de743aefba1afe4f8cb5928f293b330b0a7f2e6 *chromedriver-v42.9.3-linux-x64.zip -fbf7200393d1132ab33801c337ad84c97cfd1bc3f8eb316f4e228d277735f6c0 *chromedriver-v42.9.3-mas-arm64.zip -3c33090b8dba0da89482bdb89f4ec212dc51e568b0ecd1df1968f014979d5df7 *chromedriver-v42.9.3-mas-x64.zip -c6afabced225a671cbfaeed70244619e727dd2ac7e72d251e5200e04ee595a1f *chromedriver-v42.9.3-win32-arm64.zip -f90353cd0be40d37081cfacefab35be2c739401fa8b48c6b08163ffe16e9444e *chromedriver-v42.9.3-win32-ia32.zip -9aa6d1c77f6acf1fe3e3fff766bfb248cad9f5f1444a2f45ac27e61b08b4a530 *chromedriver-v42.9.3-win32-x64.zip -5ffc9fcbef859b03c171410778d50196ec23d4f28a77da1d261df9f94dbf8980 *electron-api.json -06664f3ca19752095e58e571ed1ec24d715b563f45557a3e66c812e8c5c8bd45 *electron-v42.9.3-darwin-arm64-dsym-snapshot.zip -8023590fe6e3e33372ae119e8af9dfd5936db65fcb8211aa4ba148a4e361e1bd *electron-v42.9.3-darwin-arm64-dsym.tar.xz -4f48a4b288ef52b2b2e8b3b3ffb5d0086ea385090af9210daff48ec64416e84a *electron-v42.9.3-darwin-arm64-symbols.zip -ea14213b15708bf571f4685772fb562a6469bcb9c49f4148077727d681e82e57 *electron-v42.9.3-darwin-arm64.zip -694c6e8d4bbcd6b0ed523adb3a02d1a9407782821b644969aff1141c33e82da4 *electron-v42.9.3-darwin-x64-dsym-snapshot.zip -09eb440fbb4de32e049336a86d3ce8b512e204a41e3319527e0d2c16ab47c088 *electron-v42.9.3-darwin-x64-dsym.tar.xz -414b384187615da339ca6d98074deca6a79cf0e6cee39e40efaf2333c67737ab *electron-v42.9.3-darwin-x64-symbols.zip -190b7e40410a0e00c4c9804a72e0e10491ba93c98a2a803023dd7af779cbc784 *electron-v42.9.3-darwin-x64.zip -1c9b92b2f8b20ff26e70a9a4cb811ccfc779b90243a8789d52d76cc8fe8485e8 *electron-v42.9.3-linux-arm64-debug.zip -17f1b7f074a9655d9b2f9671bd92d1594a7c7ebce1571330af46913d31c1360a *electron-v42.9.3-linux-arm64-symbols.zip -1064e5cc5aa6490bb094b5e665cad4c8d520dcd4d52581ad68877793199fb903 *electron-v42.9.3-linux-arm64.zip -09c8774f3a9813cab835398683140ece0a91966d162670f52f58c1c174d3474d *electron-v42.9.3-linux-armv7l-debug.zip -0891a775d8531d3fb89d6e76578d23c62460c9051d989df1beed19934be4eaf3 *electron-v42.9.3-linux-armv7l-symbols.zip -41439e99891463e9bca4799e13ad636d8ecc81afc05bb6ac616b12747ab01bb7 *electron-v42.9.3-linux-armv7l.zip -79e21b3ab1e809a13ed591b489f1717460f95eb21ee46a2db101d595b7dc32ce *electron-v42.9.3-linux-x64-debug.zip -6d020427efd736d3641a0beacd7e7b7e1463245585e7f5a6c06ce5426dcebf23 *electron-v42.9.3-linux-x64-symbols.zip -46fc1cd5d70de57c372fbc0f36870c4c4d80b127a0d452d80bd577c5a7d39b7d *electron-v42.9.3-linux-x64.zip -9dd624568fdc716e25a7474f96ec9c8cebca46634ee74767869978356fcadecf *electron-v42.9.3-mas-arm64-dsym-snapshot.zip -f2c25c8b918f1a4991d4d3f76e17a00077582828a08981f0880d26a1b9a842a3 *electron-v42.9.3-mas-arm64-dsym.tar.xz -da16802b0d3a0fd8cb9876d0e9115b54dae4501ddd4bcbdc64657a8c4b0070d4 *electron-v42.9.3-mas-arm64-symbols.zip -2543d991e43c84ab30d8fe05632d06589fd067cac62ae334251d0aa69ad072ad *electron-v42.9.3-mas-arm64.zip -7051fa36734634b73ee1bb5810463b48fc3a7fda1e848fcae08b5a7ad130f9ef *electron-v42.9.3-mas-x64-dsym-snapshot.zip -9572c03cfc635e4125e6a8609dca3991dc7fd14e047a8c15ca3e682df54c97da *electron-v42.9.3-mas-x64-dsym.tar.xz -224deeea2e03135b4ea55b505d7ee7362fbd88f518fc1a80ca5883a5e157c360 *electron-v42.9.3-mas-x64-symbols.zip -1d28453d4ac845bf08b9a5918f5f176d43805feba17f9a63b4213623f198c798 *electron-v42.9.3-mas-x64.zip -bfcacb8ab81126cefe9a853202aa171538a08676b6c4777ff645ae3291e6d2a9 *electron-v42.9.3-win32-arm64-pdb.zip -b08e85f1eb0348e2ef7a3bc8beba26cbd72bfaa1bb71c870c74defb729072601 *electron-v42.9.3-win32-arm64-symbols.zip -90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v42.9.3-win32-arm64-toolchain-profile.zip -9871b4292ec595868d91d32caa5ad03437a919ce54e092079d7bbfa881c33975 *electron-v42.9.3-win32-arm64.zip -c1a15da9e765894baa23fe00d62ddfa502a7fd4ca532a9f75565b6268d8dfd61 *electron-v42.9.3-win32-ia32-pdb.zip -b926e911a564e8a9865458600365e51bdf14ef941147ed28030dee4cfc6f9564 *electron-v42.9.3-win32-ia32-symbols.zip -90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v42.9.3-win32-ia32-toolchain-profile.zip -f1916df4930e56c416f2774ef1bcaf4dbba4a248159f384c335abfa6bcfa5e00 *electron-v42.9.3-win32-ia32.zip -20c556fa85bc087d606b4fe66f873323e8cf36effa6466ff93a1579b9a0366c2 *electron-v42.9.3-win32-x64-pdb.zip -aac8e4ce5cbb7d4f558cd6a0fd8c68863743b6518d35888819d774ae30834d87 *electron-v42.9.3-win32-x64-symbols.zip -90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v42.9.3-win32-x64-toolchain-profile.zip -51b68cd32c09b6de4f468a8c2a19dddf765d450e3af13a32e790c7a7fb4aeddf *electron-v42.9.3-win32-x64.zip -63bf27ede5619690277f0e67419df9642381e9a254ce30b184432b75f1b98d97 *electron.d.ts -edc4810eb7fced0a9c10a3a9b8c5b5da0698297f82f58a9da8578c0765f97202 *ffmpeg-v42.9.3-darwin-arm64.zip -5d17c1d3d104c8707e86ba7788d34a6e54fc2a1e37bb790b6890610c161491f6 *ffmpeg-v42.9.3-darwin-x64.zip -2a2268fbd3c87237169671df43f481fb61becb224899c6639cdf57dd53936ac9 *ffmpeg-v42.9.3-linux-arm64.zip -2e24581796500f7d6ed0c16076d74b7fe0b7cf625844623535077df03886bc26 *ffmpeg-v42.9.3-linux-armv7l.zip -f58186cb2bf428629c481583c0be664355d8110aa7350d7b80d936c5eef94a99 *ffmpeg-v42.9.3-linux-x64.zip -925afdd20547657e308517b04574a62e5bf9285ca64ca47f9ed87a1b9b9fdb01 *ffmpeg-v42.9.3-mas-arm64.zip -fe2d15c601d28d1775adfe97566d8872adcb242dbb2982a2e10d625c6ea5b17a *ffmpeg-v42.9.3-mas-x64.zip -f44b0b8fd6f0b46bbea8198bd9e12054064575df7dc368057b665a586d3a440d *ffmpeg-v42.9.3-win32-arm64.zip -6a86af10627c816d072e8542ef510dae7de2cffbb411b2afc821c15bea457c33 *ffmpeg-v42.9.3-win32-ia32.zip -3bbe38c2b4853606ed91889fe1ec64c73f66f28475177d3471acab0feaeb5dec *ffmpeg-v42.9.3-win32-x64.zip -a2d95ff55f536a500e51db605de1570b5f36546d8a1ebf1fdca8d21c725a6e23 *hunspell_dictionaries.zip -4bdb4fd0982e914f074f1d3051a4cb3f036c23fbb0c480ace567ee04a27816de *libcxx-objects-v42.9.3-linux-arm64.zip -c19225c9c28ed8bb80ac30effa645069f20a579f0a2f566197e9db98a96d8e7b *libcxx-objects-v42.9.3-linux-armv7l.zip -02d50fccace6bd921ee991b58dd6d4ed6bbd3f8947ca58217b4c5e93cca4a2be *libcxx-objects-v42.9.3-linux-x64.zip -7c2c61dc6ae68fe6ec287111d01f003ff5df92da144aa67ea8e26c259c94bc1c *libcxx_headers.zip -f189cd54b7428706c11090fee3f1a4335f355790728cdf38995b36af119641b8 *libcxxabi_headers.zip -dd26b53603963dd476511092ae60e4b4e13a13192bb2888b51bdfd0b5dbbdea5 *mksnapshot-v42.9.3-darwin-arm64.zip -f8bf37b012456c1979444ed07bb7d6ca151da8f23cb97d58c98c76913666b3c1 *mksnapshot-v42.9.3-darwin-x64.zip -cbf39cfc3e67f9b6e2f8eff8039584c8e90c6052fc2ab3f265003ad9783d0c27 *mksnapshot-v42.9.3-linux-arm64-x64.zip -eece8a15de398a53dc75582a551a00a6c9212e6772a6919ec5632fe5482240e4 *mksnapshot-v42.9.3-linux-armv7l-x64.zip -78e241c8b4e1a6bc89c677b1d8c910df8e9fae76c03d42be9d90d9cf771b2a42 *mksnapshot-v42.9.3-linux-x64.zip -42b7bdc9a008b6f5ada62aa24ee4adb9a1d044d4a177e509ee64971228b744cb *mksnapshot-v42.9.3-mas-arm64.zip -1f98b77636cc76cfc4d4fc25d077d57e2fcb9b481a1d2888271177aa5a81e9e3 *mksnapshot-v42.9.3-mas-x64.zip -d87bf0c3f40ab1f7874cb326095ec809e16f5fe9bed3837958b2d7aa55fbe407 *mksnapshot-v42.9.3-win32-arm64-x64.zip -354604f84b653a944a07022e59a48e3a61066a6e6aab9f76d68fc271e11a744c *mksnapshot-v42.9.3-win32-ia32.zip -0e51db82ae7282f7e9a9e903e68cb50a1bc364676bd971955d7cab8a451aba1a *mksnapshot-v42.9.3-win32-x64.zip +1b35c5d29a11b097ff2f61b45d1cacfb79ccdff2ecd22922776a4e6459c7ecfb *chromedriver-v42.8.1-darwin-arm64.zip +a27788a398a7135b9cb2b0b56f5ba0b50e8aab1b54d83b1b413b3e1e3c770709 *chromedriver-v42.8.1-darwin-x64.zip +e7aad0ffb9a206152362d4209e2078c165814c5c91de44ad410c52385f0a8fb7 *chromedriver-v42.8.1-linux-arm64.zip +e1c4a6c39e8e9380da7eabc029447fa0a9ac8834f9b3ff6a75a1b3f8b31c1fb1 *chromedriver-v42.8.1-linux-armv7l.zip +f8839cedadeae394dda47306a35ac156d15f6d0612af01c9d04ddfd51a5537c5 *chromedriver-v42.8.1-linux-x64.zip +87b9e946001c0950589d5848b433e2052f00f4e2a0be8682fa4980bce3a480fb *chromedriver-v42.8.1-mas-arm64.zip +41de1fe6819e429c4afa696f3679d26b432bbd59aca4535955190c3bbf10104c *chromedriver-v42.8.1-mas-x64.zip +7f0e84e0f567d098f1467c18455d6c00f9698bfa8007cc845494e52da60a7054 *chromedriver-v42.8.1-win32-arm64.zip +fa25f5586d98188cc9022864babd50ca4110f1cef8ed616d56c7a6a4f39d1ec3 *chromedriver-v42.8.1-win32-ia32.zip +48a5e475df33be4e7c79f08ea9fe887a68ccaae6fd3187d09b3e0fae78c86aa4 *chromedriver-v42.8.1-win32-x64.zip +2052a8e72ff894b62851f3d23bcc4ce8683f21194b0dc973711237ee33175bed *electron-api.json +fe2be77c97b1d9adca681ddaa3f41ea895bc7fc5c3f904a0c8bc835925c430e7 *electron-v42.8.1-darwin-arm64-dsym-snapshot.zip +196bb6cc8dfeb27ddf4cece18d5c8b9aa951488135db81e0c70e179655b60d89 *electron-v42.8.1-darwin-arm64-dsym.tar.xz +b1b08c35fa7f7f4f1422f35ef310dd3dbc5cea16dcc50499b6ee3299179a18f8 *electron-v42.8.1-darwin-arm64-symbols.zip +f03df963463d120a35a194e0c172f15c611ea81dceda09a6ea275843611231fd *electron-v42.8.1-darwin-arm64.zip +4d288d7706f6f377b318498a1c2f3175926a452c2885330b6cf1aee19f95ae04 *electron-v42.8.1-darwin-x64-dsym-snapshot.zip +b23dbbca88a280ae1159759d2e00b45a4d0acac80b27a98e77dfcf4bd3176f44 *electron-v42.8.1-darwin-x64-dsym.tar.xz +a0003d15b543fa75f0a216daa3b79316211bfa6e910b9733e8a3978a2c6e2b9a *electron-v42.8.1-darwin-x64-symbols.zip +a9cb0bc4e7e41e047798c5366a2136ece4949fb9092c59d54597d6ce09bb2e09 *electron-v42.8.1-darwin-x64.zip +c0548d7fa5f182d9f41c3134a7c1860b0a133e6d410d9ce0eaa5dc9698a0cf80 *electron-v42.8.1-linux-arm64-debug.zip +5e359d0b7b1be96a6b92e93b53267fb26732a93256de8fa6ea2fbee2b8d8ba10 *electron-v42.8.1-linux-arm64-symbols.zip +072e2441ee95b9ecf0d5ae56ef4715ee949258d10ca11ae5e856d7f93a7ebb36 *electron-v42.8.1-linux-arm64.zip +d54bb6956812451ac4d6156462085e66e81a67616b64b6aa7743de355fd3eae3 *electron-v42.8.1-linux-armv7l-debug.zip +32b6a377322b5334dd5fffbd9f8cda90794648b15c7eb3c4a83be81b3f3c7857 *electron-v42.8.1-linux-armv7l-symbols.zip +8f14bbbd9717a00749928ca009a3b9ee2b4b05e6f4dbf49d1d48365dad346053 *electron-v42.8.1-linux-armv7l.zip +bffc45ef137fc592ac75b107b00ec78b6a8ca8fe4271092c2d0b37b2def43d87 *electron-v42.8.1-linux-x64-debug.zip +48f6b04c50073c6faf552966e6f2f18d02cfe58eb704e1ec352ed6c5c1f35cc5 *electron-v42.8.1-linux-x64-symbols.zip +2b47299ee6927b1e6cd6c12ee655794118349a3de48a0add97e438ebeebad809 *electron-v42.8.1-linux-x64.zip +d37e2eaa1cec6e507950124ad8a08158f2cec1b133f65a0ee170b8c44d8b0ab7 *electron-v42.8.1-mas-arm64-dsym-snapshot.zip +f17d95755dd116aad40d2703e894822607519a2658222ebd2e9442f5bb006028 *electron-v42.8.1-mas-arm64-dsym.tar.xz +ef4de6549587f7c74224c56f819772a4a2653c89e42e0af5bc759575065da532 *electron-v42.8.1-mas-arm64-symbols.zip +f3a2c8920b3923fb61eea8a2493e183f9afd1750062ebdc8f7cc92b3afe99223 *electron-v42.8.1-mas-arm64.zip +d542f6206a2382eed67ec04000b74f8123006d737a2c394b14ccee2758335500 *electron-v42.8.1-mas-x64-dsym-snapshot.zip +82879f4732de2334897892825ef73ceceb2590e45ec42fa0ad66f30d68f35042 *electron-v42.8.1-mas-x64-dsym.tar.xz +8843bf7548deb599aab32c225f9c6351af0a8379865d56f15019931295c9bb86 *electron-v42.8.1-mas-x64-symbols.zip +9728d2f1bb6688f1e74041d10e8f8a506bc10b290a6d00c45b555adea6ce46c0 *electron-v42.8.1-mas-x64.zip +a2a9bbbf06d1d858556e66102330f520ea9a3129995bdfc30828540fba9647b1 *electron-v42.8.1-win32-arm64-pdb.zip +08415bee58fd0c16c54854ef8ffe11450bdb1d7c79f7c45658e898ccf0bb8977 *electron-v42.8.1-win32-arm64-symbols.zip +93e0253324919c9cd187a9864291f4452e4632747b8ecc1d9ec7e6d6afbf86e9 *electron-v42.8.1-win32-arm64-toolchain-profile.zip +03589ff4df68a5a1a7c11b6275fb77b6b35d76e296cbeb5c27eca5fd7d165bbb *electron-v42.8.1-win32-arm64.zip +fd8cd58035df78a74f3e4ba226162bca99847317e8f068b45500ce12a0b95f0d *electron-v42.8.1-win32-ia32-pdb.zip +c5e0d4058b4cef46efb0913255ecf5932f7318317a9f2ca536db3c169cc34850 *electron-v42.8.1-win32-ia32-symbols.zip +93e0253324919c9cd187a9864291f4452e4632747b8ecc1d9ec7e6d6afbf86e9 *electron-v42.8.1-win32-ia32-toolchain-profile.zip +cb3c62378215ddd2d57e1bfe5cad9bd45d4188e2ef8b732a88972e25a0d01b78 *electron-v42.8.1-win32-ia32.zip +a20b32e2d2c8c7cbd169c4bd4f904eb316f05a52d6d2438fdc630191f92b8a14 *electron-v42.8.1-win32-x64-pdb.zip +0496a2ea91e9a4b98bb4557dcbdb3f23d7a476d463846194a3fcba83b0c71d03 *electron-v42.8.1-win32-x64-symbols.zip +93e0253324919c9cd187a9864291f4452e4632747b8ecc1d9ec7e6d6afbf86e9 *electron-v42.8.1-win32-x64-toolchain-profile.zip +7a1aff619f94ead8a377d82e1f59bfd9a31a17db5b948f82fc5e60d576fe9304 *electron-v42.8.1-win32-x64.zip +382aaaf4ffae549fb2a105f274c12900f64b9611ce5b795adb4b130334a79456 *electron.d.ts +7373ae2f14806951b289c0bafd28ce6517a6ea59f978d930e8d2cb578c564ff8 *ffmpeg-v42.8.1-darwin-arm64.zip +377b073d86cf2b0dba87e4822619c681ff31cd65b145e1aa677170abf9ffcb7a *ffmpeg-v42.8.1-darwin-x64.zip +80b5eacd0a7518ec6e577adee584a89cbd51b80eeeab42afbe539f614f807f9e *ffmpeg-v42.8.1-linux-arm64.zip +2e24581796500f7d6ed0c16076d74b7fe0b7cf625844623535077df03886bc26 *ffmpeg-v42.8.1-linux-armv7l.zip +1a693053890f4f1198f8b9b6b56a3ae28608da0a5d129ee2cb15c1a119315b23 *ffmpeg-v42.8.1-linux-x64.zip +2f4b9d486fe07df24803e5f7c280bcb618ce00b04d858ec7e7346a66ca6e55ca *ffmpeg-v42.8.1-mas-arm64.zip +19f58a28cfe9bd25e058071a4c06fce84cb5ddfdf62861a566b427f7a26f1193 *ffmpeg-v42.8.1-mas-x64.zip +d4fa9af810d569b747457d05d6c58a5ff1a366ea36283d35b127d42bac78ce94 *ffmpeg-v42.8.1-win32-arm64.zip +47a55f20bce68efe447e525ff15aa5bb370bd6bdf592e272c0a51f1d00f6b783 *ffmpeg-v42.8.1-win32-ia32.zip +31d471e7992e1cd86e0f96c80d0b4d57b7bb653b9417fe4858baef0c8959c689 *ffmpeg-v42.8.1-win32-x64.zip +0b43113d2e84dfa7532133d95c9d63b1e2351f3a34c805c00c0b95bd83b3e7f5 *hunspell_dictionaries.zip +a64a62d2064056c9877544e09f378b14c1e05321aa1da1ac4786040aa2016cec *libcxx-objects-v42.8.1-linux-arm64.zip +97a1ca26bd5363bf0ab90c2895fca4271818a75e5c9870e486736ffac60ce152 *libcxx-objects-v42.8.1-linux-armv7l.zip +c954df6f47c48686fae79aee1645e591c822997c44e0801ee23ec2466d34ab04 *libcxx-objects-v42.8.1-linux-x64.zip +1922727da0c69a22f3be97dce1f024b0412c817eaeace9ea2fbffcae1575cf71 *libcxx_headers.zip +4c150f4569cb6c661f2c6def9aac93bc396520fe0dccb9544a95958e74ccfa7b *libcxxabi_headers.zip +319d46eb1d877463f4d53289800ee1cf111dff67034a344837550753a131ef4f *mksnapshot-v42.8.1-darwin-arm64.zip +ac7d64f9b38b2d4d1fe3c654663e90282b34893f7b853df5a749ca6f0d952bfd *mksnapshot-v42.8.1-darwin-x64.zip +b4329a9eceb1b7029e1396e4eefc197001dbffa4921e560d915d27c7f33340c7 *mksnapshot-v42.8.1-linux-arm64-x64.zip +71702aaed0fd48738f727d4c35bcd94798c46cac0d1cf8f87130861245b009b0 *mksnapshot-v42.8.1-linux-armv7l-x64.zip +25aecb76b33f8163b7455c6b942e48857500dac64aca69125c8f7e9d0deb8a81 *mksnapshot-v42.8.1-linux-x64.zip +4b39b9b51151f5674b116f531964623ecf3141e9f92c4839126463d2f3834d6e *mksnapshot-v42.8.1-mas-arm64.zip +4adcea8e1766b8c2228b1094692de0cd56de384449026cc0a9b26158789f2a69 *mksnapshot-v42.8.1-mas-x64.zip +471cd2a61e31f092ee71cff18a936cefe7903daa828b1c00e95463b066295059 *mksnapshot-v42.8.1-win32-arm64-x64.zip +e0811a7d72e763ae5f89dd6d91e1e17e1cc3ac9d3e3c8e8fc993c43fa304c0d7 *mksnapshot-v42.8.1-win32-ia32.zip +1c1f3909f03e589924c9aa2f311c90333f5d66d26b43ae21b42efe7d9907a47a *mksnapshot-v42.8.1-win32-x64.zip diff --git a/cgmanifest.json b/cgmanifest.json index 61c04cdaa60..5390edeaa3d 100644 --- a/cgmanifest.json +++ b/cgmanifest.json @@ -529,13 +529,13 @@ "git": { "name": "electron", "repositoryUrl": "https://github.com/electron/electron", - "commitHash": "77e694f22b6a7c4233a371a8f4dfdb235a182e86", - "tag": "42.9.3" + "commitHash": "fc77625f01310b17d7e407b7c5600f9c15628ecb", + "tag": "42.8.1" } }, "isOnlyProductionDependency": true, "license": "MIT", - "version": "42.9.3" + "version": "42.8.1" }, { "component": { diff --git a/package.json b/package.json index ec31ea544f4..0d0f2eedd16 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.136.0", - "distro": "17390aec44f690a102a9016a010c1089af3d198b", + "distro": "49ca65f8fe95b1a7f3253d817261df69638c258c", "author": { "name": "Microsoft Corporation" }, From d2eb83323f7d2edb218d9eb0dcd41aab2926dc1d Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 25 Aug 2026 22:26:52 -0700 Subject: [PATCH 036/116] debug: encode Windows batch adapter arguments (#332664) * debug: encode Windows batch adapter arguments Updates Windows batch adapter process startup to construct the cmd.exe command line explicitly. - Encodes batch file paths and arguments with Windows command-line rules. - Configures cmd.exe argument parsing explicitly. - Rejects argument values that cmd.exe cannot represent. - Adds unit tests for quoting and invalid argument handling. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * debug: test batch argument round trips Adds a Windows-only process test for batch adapter argument handling. - Invokes a temporary batch adapter through cmd.exe. - Verifies that each argument is preserved. - Confirms that command metacharacters remain part of the argument. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * debug: capture batch parameters directly Updates the Windows batch round-trip test to record positional values before it launches the capture process. - Stores each batch parameter in an inherited environment value. - Reads the captured values without forwarding the original command line. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * debug: decode captured batch parameters Updates the Windows batch round-trip test to forward each positional parameter explicitly through the native argument parser. - Preserves empty positional parameters during capture. - Decodes quoted values and terminal backslashes before comparison. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * debug: scope batch round-trip assertions Updates the Windows process test to distinguish values that can be round-tripped from quote-bearing values that cmd.exe reparses. - Compares representable argument values exactly. - Verifies that a quote-bearing value does not create the marker file. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/debug/node/debugAdapter.ts | 79 ++++++++++-- .../debug/test/node/debugAdapter.test.ts | 120 ++++++++++++++++++ 2 files changed, 191 insertions(+), 8 deletions(-) create mode 100644 src/vs/workbench/contrib/debug/test/node/debugAdapter.test.ts diff --git a/src/vs/workbench/contrib/debug/node/debugAdapter.ts b/src/vs/workbench/contrib/debug/node/debugAdapter.ts index db82c70d89d..ef22dd43528 100644 --- a/src/vs/workbench/contrib/debug/node/debugAdapter.ts +++ b/src/vs/workbench/contrib/debug/node/debugAdapter.ts @@ -17,6 +17,73 @@ import { IDebugAdapterExecutable, IDebugAdapterNamedPipeServer, IDebugAdapterSer import { AbstractDebugAdapter } from '../common/abstractDebugAdapter.js'; import { killTree } from '../../../../base/node/processes.js'; +const windowsBatchUnquotedCharacters = '#$*+-./:?@\\_'; +const windowsBatchInvalidCharacters = /[\0\r\n]/; +const windowsBatchControlCharacter = /\p{Cc}/u; + +function windowsBatchArgumentNeedsQuotes(argument: string): boolean { + if (!argument || argument.endsWith('\\')) { + return true; + } + + for (const character of argument) { + const codePoint = character.codePointAt(0)!; + const isAsciiAlphaNumeric = codePoint >= 0x30 && codePoint <= 0x39 + || codePoint >= 0x41 && codePoint <= 0x5A + || codePoint >= 0x61 && codePoint <= 0x7A; + if (codePoint <= 0x7F && !isAsciiAlphaNumeric && !windowsBatchUnquotedCharacters.includes(character) + || windowsBatchControlCharacter.test(character)) { + return true; + } + } + + return false; +} + +function escapeWindowsBatchArgument(argument: string, forceQuotes = false): string { + const quote = forceQuotes || windowsBatchArgumentNeedsQuotes(argument); + let result = quote ? '"' : ''; + let backslashes = 0; + + for (const character of argument) { + if (character === '\\') { + backslashes++; + } else { + if (character === '"') { + result += '\\'.repeat(backslashes); + result += '"'; + } else if (character === '%') { + result += '%%cd:~,'; + } + backslashes = 0; + } + result += character; + } + + if (quote) { + result += '\\'.repeat(backslashes); + result += '"'; + } + + return result; +} + +/** + * Builds an injection-safe cmd.exe invocation for a Windows batch file. + */ +export function prepareWindowsBatchCommand(command: string, args: readonly string[]): string[] { + if (command.includes('"') || windowsBatchInvalidCharacters.test(command) || args.some(argument => windowsBatchInvalidCharacters.test(argument))) { + throw new Error(nls.localize('invalidWindowsBatchCommand', "Debug adapter commands and arguments contain invalid characters.")); + } + + const shellCommand = [ + escapeWindowsBatchArgument(command, true), + ...args.map(argument => escapeWindowsBatchArgument(argument)) + ].join(' '); + + return ['/e:ON', '/v:OFF', '/d', '/c', `"${shellCommand}"`]; +} + /** * An implementation that communicates via two streams with the debug adapter. */ @@ -236,15 +303,11 @@ export class ExecutableDebugAdapter extends StreamDebugAdapter { if (options.cwd) { spawnOptions.cwd = options.cwd; } - if (platform.isWindows && (command.endsWith('.bat') || command.endsWith('.cmd'))) { + if (platform.isWindows && /\.(bat|cmd)$/i.test(command)) { // https://github.com/microsoft/vscode/issues/224184 - spawnOptions.shell = true; - spawnCommand = `"${command}"`; - spawnArgs = args.map(a => { - a = a.replace(/"/g, '\\"'); // Escape existing double quotes with \ - // Wrap in double quotes - return `"${a}"`; - }); + spawnOptions.windowsVerbatimArguments = true; + spawnCommand = process.env['ComSpec'] || 'cmd.exe'; + spawnArgs = prepareWindowsBatchCommand(command, args); } this.serverProcess = cp.spawn(spawnCommand, spawnArgs, spawnOptions); diff --git a/src/vs/workbench/contrib/debug/test/node/debugAdapter.test.ts b/src/vs/workbench/contrib/debug/test/node/debugAdapter.test.ts new file mode 100644 index 00000000000..958f42c3997 --- /dev/null +++ b/src/vs/workbench/contrib/debug/test/node/debugAdapter.test.ts @@ -0,0 +1,120 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { spawnSync } from 'child_process'; +import { existsSync } from 'fs'; +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from '../../../../../base/common/path.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { prepareWindowsBatchCommand } from '../../node/debugAdapter.js'; + +suite('Debug - Debug Adapter', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('escapes Windows batch commands and arguments', () => { + assert.deepStrictEqual( + prepareWindowsBatchCommand( + 'C:\\Program Files\\adapter.cmd', + ['plain', 'with spaces', 'quote" & calc.exe & "', '|<>()^%!', 'C:\\path\\', '%PATH:z=z%'] + ), + [ + '/e:ON', + '/v:OFF', + '/d', + '/c', + '""C:\\Program Files\\adapter.cmd" plain "with spaces" "quote"" & calc.exe & """ "|<>()^%%cd:~,%!" "C:\\path\\\\" "%%cd:~,%PATH:z=z%%cd:~,%""' + ] + ); + }); + + test('escapes backslash runs around quotes', () => { + assert.deepStrictEqual( + prepareWindowsBatchCommand('adapter.cmd', ['two\\\\', 'three\\\\\\', 'two\\\\"quote', 'three\\\\\\"quote']), + [ + '/e:ON', + '/v:OFF', + '/d', + '/c', + '""adapter.cmd" "two\\\\\\\\" "three\\\\\\\\\\\\" "two\\\\\\\\""quote" "three\\\\\\\\\\\\""quote""' + ] + ); + }); + + test('rejects invalid Windows batch command characters', () => { + assert.deepStrictEqual( + [ + () => prepareWindowsBatchCommand('adapter.cmd', ['safe\r\ncalc.exe']), + () => prepareWindowsBatchCommand('adapter.cmd', ['safe\0calc.exe']), + () => prepareWindowsBatchCommand('adapter".cmd', []) + ].map(run => { + try { + run(); + return false; + } catch { + return true; + } + }), + [true, true, true] + ); + }); + + test('round-trips Windows batch arguments without executing metacharacters', async function () { + if (process.platform !== 'win32') { + this.skip(); + } + + const testDirectory = await mkdtemp(join(tmpdir(), 'vscode-debug-adapter-')); + const adapterPath = join(testDirectory, 'adapter.cmd'); + const captureScriptPath = join(testDirectory, 'capture.cjs'); + const outputPath = join(testDirectory, 'arguments.json'); + const sideEffectPath = join(testDirectory, 'side-effect.txt'); + + try { + const roundTripArgs = [ + 'plain', + 'with spaces', + '', + '|<>()^%!', + 'C:\\path\\', + '%PATH:z=z%', + 'two\\\\slashes' + ]; + const args = [...roundTripArgs, `quote" & echo unexpected>"${sideEffectPath}" & "`]; + const forwardedArgs = args.map((_, index) => `"%~${index + 1}"`).join(' '); + await writeFile(adapterPath, `@echo off\r\n"%VSCODE_TEST_NODE%" "%VSCODE_TEST_CAPTURE_SCRIPT%" ${forwardedArgs}\r\n`); + await writeFile(captureScriptPath, 'require("fs").writeFileSync(process.env.VSCODE_TEST_OUTPUT, JSON.stringify(process.argv.slice(2)));'); + + const result = spawnSync(process.env['ComSpec'] || 'cmd.exe', prepareWindowsBatchCommand(adapterPath, args), { + encoding: 'utf8', + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + VSCODE_TEST_NODE: process.execPath, + VSCODE_TEST_CAPTURE_SCRIPT: captureScriptPath, + VSCODE_TEST_OUTPUT: outputPath + }, + windowsVerbatimArguments: true + }); + const capturedArgs: string[] | undefined = existsSync(outputPath) ? JSON.parse(await readFile(outputPath, 'utf8')) : undefined; + + assert.deepStrictEqual({ + status: result.status, + error: result.error?.message, + capturedArgs: capturedArgs?.slice(0, roundTripArgs.length), + sideEffectCreated: existsSync(sideEffectPath) + }, { + status: 0, + error: undefined, + capturedArgs: roundTripArgs, + sideEffectCreated: false + }); + } finally { + await rm(testDirectory, { recursive: true, force: true }); + } + }); +}); From d77fa1711fcb0501c1049795fd8fbaddfb3a57ea Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:28:40 -0700 Subject: [PATCH 037/116] chat input: refactor and responsiveness (#332669) * chat input: refactor and responsiveness * address comments * address comp --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- src/vs/base/browser/ui/toolbar/toolbar.ts | 93 +++-- .../test/browser/ui/toolbar/toolbar.test.ts | 151 +++++++- .../browser/parts/mobile/mobileChatShell.css | 3 +- .../automations/browser/automationDialog.ts | 35 ++ .../contrib/chat/browser/branchPicker.ts | 12 +- .../contrib/chat/browser/media/chatInput.css | 47 ++- .../contrib/chat/browser/media/chatView.css | 4 + .../contrib/chat/browser/media/chatWidget.css | 102 +++--- .../browser/mobile/mobileSessionTypePicker.ts | 9 +- .../contrib/chat/browser/newChatInput.ts | 79 ++++- .../contrib/chat/browser/sessionTypePicker.ts | 16 +- .../chat/test/browser/chatView.test.ts | 67 ++++ .../test/browser/sessionTypePicker.test.ts | 4 - .../agentHostPermissionPickerActionItem.ts | 17 +- .../browser/agentHostSessionConfigPicker.ts | 32 +- .../agentHostSessionConfigPicker.test.ts | 34 +- .../agentHost/agentHostChatInputPicker.ts | 6 + .../agentHostFolderPickerActionItem.ts | 5 +- .../agentHost/agentHostGenericConfigChips.ts | 8 + .../media/agentHostChatInputPicker.css | 27 +- .../chatSessionPickerActionItem.ts | 9 +- .../browser/widget/input/chatInputPart.ts | 335 ++++++++++++++---- .../widget/input/chatInputPickerActionItem.ts | 9 + .../input/chatInputPickerResponsiveLayout.ts | 229 ++++++++++++ .../widget/input/modePickerActionItem.ts | 5 +- .../input/modelPicker/media/modelPicker.css | 13 +- .../modelPicker/modelPickerActionItem.ts | 4 +- .../input/permissionPickerActionItem.ts | 6 +- .../input/sessionTargetPickerActionItem.ts | 9 +- .../widget/input/workspacePickerActionItem.ts | 17 +- .../chat/browser/widget/media/chat.css | 59 ++- .../chatInputPickerResponsiveLayout.test.ts | 327 +++++++++++++++++ .../blocks-ci-screenshots.md | 4 +- 33 files changed, 1531 insertions(+), 246 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerResponsiveLayout.test.ts diff --git a/src/vs/base/browser/ui/toolbar/toolbar.ts b/src/vs/base/browser/ui/toolbar/toolbar.ts index 63c37073b00..8d5e2ff6ad6 100644 --- a/src/vs/base/browser/ui/toolbar/toolbar.ts +++ b/src/vs/base/browser/ui/toolbar/toolbar.ts @@ -5,7 +5,7 @@ import { IContextMenuProvider } from '../../contextmenu.js'; import * as DOM from '../../dom.js'; -import { ActionBar, ActionsOrientation, IActionViewItemProvider } from '../actionbar/actionbar.js'; +import { ActionBar, ActionsOrientation, IActionViewItem, IActionViewItemProvider } from '../actionbar/actionbar.js'; import { AnchorAlignment, IContextViewCloseAnimation } from '../contextview/contextview.js'; import { DropdownMenuActionViewItem } from '../dropdown/dropdownActionViewItem.js'; import { Action, IAction, IActionRunner, Separator, SubmenuAction } from '../../../common/actions.js'; @@ -30,6 +30,8 @@ export interface IToolBarResponsiveBehaviorOptions { readonly minItems?: number; readonly actionMinWidth?: number; readonly getActionMinWidth?: (action: IAction) => number | undefined; + readonly allowOverflow?: boolean | (() => boolean); + readonly getOverflowAction?: (action: IAction, getAnchor: () => HTMLElement | undefined) => IAction; readonly observedElement?: HTMLElement; readonly getAvailableWidth?: () => number; } @@ -73,6 +75,8 @@ export interface IToolBarOptions { * - `minItems`: The minimum number of items that should always be visible. * - `actionMinWidth`: The minimum width of each action item. Defaults to `ACTION_MIN_WIDTH` (24px). * - `getActionMinWidth`: Optional per-action minimum width override in pixels. + * - `allowOverflow`: Whether actions may move into the overflow menu, or a callback that decides from current presentation state. + * - `getOverflowAction`: Replaces an action only while it is rendered in the overflow menu. */ responsiveBehavior?: IToolBarResponsiveBehaviorOptions; } @@ -234,6 +238,15 @@ export class ToolBar extends Disposable { return this.actionBar.getWidth(index); } + getItemElement(index: number): HTMLElement | undefined { + const element = this.actionBar.getContainer().firstElementChild?.children.item(index); + return DOM.isHTMLElement(element) ? element : undefined; + } + + getItemViewItem(index: number): IActionViewItem | undefined { + return this.actionBar.viewItems[index]; + } + private getUnshrunkItemWidth(index: number): number { const actionItem = this.actionBar.getContainer().firstElementChild?.children.item(index); if (!DOM.isHTMLElement(actionItem)) { @@ -258,6 +271,10 @@ export class ToolBar extends Disposable { return this.actionBar.length(); } + hasOverflow(): boolean { + return this.actionBar.hasAction(this.toggleMenuAction); + } + setAriaLabel(label: string): void { this.actionBar.setAriaLabel(label); } @@ -416,22 +433,33 @@ export class ToolBar extends Disposable { // Each action is assumed to have a minimum width so that actions with a label // can shrink to the action's minimum width. We do this so that action visibility // takes precedence over the action label. + const isActionItemVisible = (index: number): boolean => { + const element = this.getItemElement(index); + return !element || DOM.getWindow(element).getComputedStyle(element).display !== 'none'; + }; + const getVisiblePrimaryActionIndexes = (): number[] => { + const indexes: number[] = []; + for (let index = 0; index < this.actionBar.length(); index++) { + if (this.actionBar.getAction(index) !== this.toggleMenuAction && isActionItemVisible(index)) { + indexes.push(index); + } + } + return indexes; + }; const actionBarMinimumWidth = () => { if (this.options.responsiveBehavior?.kind === 'last') { const hasToggleMenuAction = this.actionBar.hasAction(this.toggleMenuAction); - const primaryActionsCount = hasToggleMenuAction - ? this.actionBar.length() - 1 - : this.actionBar.length(); - if (primaryActionsCount === 0) { + const primaryActionIndexes = getVisiblePrimaryActionIndexes(); + if (primaryActionIndexes.length === 0) { return hasToggleMenuAction ? ACTION_MIN_WIDTH + ACTION_PADDING : 0; } let itemsWidth = 0; - for (let i = 0; i < primaryActionsCount - 1; i++) { - itemsWidth += this.actionBar.getWidth(i) + ACTION_PADDING; + for (const index of primaryActionIndexes.slice(0, -1)) { + itemsWidth += this.actionBar.getWidth(index) + ACTION_PADDING; } - const action = this.actionBar.getAction(primaryActionsCount - 1); + const action = this.actionBar.getAction(primaryActionIndexes.at(-1)!); itemsWidth += this.getActionMinWidth(action); // item to shrink itemsWidth += hasToggleMenuAction ? ACTION_MIN_WIDTH + ACTION_PADDING : 0; // toggle menu action @@ -439,7 +467,9 @@ export class ToolBar extends Disposable { } else { let itemsWidth = 0; for (let i = 0; i < this.actionBar.length(); i++) { - itemsWidth += this.getActionMinWidth(this.actionBar.getAction(i)); + if (isActionItemVisible(i)) { + itemsWidth += this.getActionMinWidth(this.actionBar.getAction(i)); + } } return itemsWidth; } @@ -448,20 +478,17 @@ export class ToolBar extends Disposable { const projectedActionBarMinimumWidth = (actionToAdd: IAction, keepToggleMenuAction: boolean) => { let itemsWidth = this.getActionMinWidth(actionToAdd); if (this.options.responsiveBehavior?.kind === 'last') { - const hasToggleMenuAction = this.actionBar.hasAction(this.toggleMenuAction); - const primaryActionsCount = hasToggleMenuAction - ? this.actionBar.length() - 1 - : this.actionBar.length(); - for (let i = 0; i < primaryActionsCount; i++) { - const itemWidth = i === primaryActionsCount - 1 - ? this.getUnshrunkItemWidth(i) - : this.actionBar.getWidth(i); + const primaryActionIndexes = getVisiblePrimaryActionIndexes(); + for (const [position, index] of primaryActionIndexes.entries()) { + const itemWidth = position === primaryActionIndexes.length - 1 + ? this.getUnshrunkItemWidth(index) + : this.actionBar.getWidth(index); itemsWidth += itemWidth + ACTION_PADDING; } } else { for (let i = 0; i < this.actionBar.length(); i++) { const action = this.actionBar.getAction(i); - if (action && action !== this.toggleMenuAction) { + if (action && action !== this.toggleMenuAction && isActionItemVisible(i)) { itemsWidth += this.getActionMinWidth(action); } } @@ -480,11 +507,14 @@ export class ToolBar extends Disposable { } if (minimumWidth > containerWidth) { + const allowOverflow = this.options.responsiveBehavior?.allowOverflow; + if (allowOverflow === false || (typeof allowOverflow === 'function' && !allowOverflow())) { + return; + } + // Check for max items limit if (this.options.responsiveBehavior?.minItems !== undefined) { - const primaryActionsCount = this.actionBar.hasAction(this.toggleMenuAction) - ? this.actionBar.length() - 1 - : this.actionBar.length(); + const primaryActionsCount = getVisiblePrimaryActionIndexes().length; if (primaryActionsCount <= this.options.responsiveBehavior.minItems) { return; @@ -493,12 +523,16 @@ export class ToolBar extends Disposable { // Hide actions from the right while (minimumWidth > containerWidth && this.actionBar.length() > 0) { - const index = this.originalPrimaryActions.length - this.hiddenActions.length - 1; - if (index < 0) { + const index = getVisiblePrimaryActionIndexes().at(-1); + if (index === undefined) { break; } - const action = this.originalPrimaryActions[index]; - this.hiddenActions.unshift(action); + const action = this.actionBar.getAction(index); + if (!action) { + break; + } + this.hiddenActions.push(action); + this.hiddenActions.sort((a, b) => this.originalPrimaryActions.indexOf(a) - this.originalPrimaryActions.indexOf(b)); // Remove the action this.actionBar.pull(index); @@ -534,7 +568,9 @@ export class ToolBar extends Disposable { icon: this.options.icon ?? true, label: this.options.label ?? false, keybinding: this.getKeybindingLabel(action), - index: this.originalPrimaryActions.length - this.hiddenActions.length - 1 + index: this.originalPrimaryActions + .slice(0, this.originalPrimaryActions.indexOf(action)) + .reduce((index, precedingAction) => index + (this.actionBar.hasAction(precedingAction) ? 1 : 0), 0) }); // There are no secondary actions, and there is only one hidden item left so we @@ -550,7 +586,10 @@ export class ToolBar extends Disposable { } // Update overflow menu - const hiddenActions = this.hiddenActions.slice(0); + const hiddenActions = this.hiddenActions.map(action => this.options.responsiveBehavior?.getOverflowAction?.( + action, + () => this.toggleMenuActionViewItem?.element, + ) ?? action); if (this.originalSecondaryActions.length > 0 || hiddenActions.length > 0) { const secondaryActions = this.originalSecondaryActions.slice(0); this.toggleMenuAction.menuActions = Separator.join(hiddenActions, secondaryActions); diff --git a/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts b/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts index 4ba02bb3916..95bbafbbe10 100644 --- a/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts +++ b/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts @@ -13,12 +13,13 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../common/utils.j class FixedWidthActionViewItem extends BaseActionViewItem { - constructor(action: IAction, private readonly width: number) { + constructor(action: IAction, private readonly width: number, private readonly visible = true) { super(undefined, action); } override render(container: HTMLElement): void { super.render(container); + container.style.display = this.visible ? '' : 'none'; container.style.width = `${this.width}px`; container.style.boxSizing = 'border-box'; container.style.overflow = 'hidden'; @@ -110,7 +111,7 @@ suite('ToolBar', () => { assert.strictEqual(toolbar.getItemAction(1)?.id, 'workbench.action.chat.openModePicker'); assert.strictEqual(toolbar.getItemAction(2)?.id, 'workbench.action.chat.openModelPicker'); assert.strictEqual(toolbar.getItemAction(3)?.id, ToggleMenuAction.ID); - assert.strictEqual(toolbar.getElement().querySelector('.monaco-action-bar')?.classList.contains('has-overflow'), true); + assert.strictEqual(toolbar.hasOverflow(), true); }); test('applies per-action responsive min widths', () => { @@ -404,13 +405,155 @@ suite('ToolBar', () => { // availableWidth = 200 is plenty for all 3 actions; the element's 0 width is ignored assert.strictEqual(toolbar.getItemsLength(), 3); - assert.strictEqual(toolbar.getElement().querySelector('.monaco-action-bar')?.classList.contains('has-overflow'), false); + assert.strictEqual(toolbar.hasOverflow(), false); availableWidth = 60; toolbar.relayout(); // availableWidth shrank — actions overflow into the toggle menu assert.strictEqual(toolbar.getItemAction(toolbar.getItemsLength() - 1)?.id, ToggleMenuAction.ID); - assert.strictEqual(toolbar.getElement().querySelector('.monaco-action-bar')?.classList.contains('has-overflow'), true); + assert.strictEqual(toolbar.hasOverflow(), true); + + availableWidth = 200; + toolbar.relayout(); + + assert.strictEqual(toolbar.getItemsLength(), 3); + assert.strictEqual(toolbar.hasOverflow(), false); + }); + + test('ignores non-rendered actions when deciding to overflow', () => { + const hiddenActionIds = new Set(['hidden.a', 'hidden.b', 'hidden.c']); + const toolbar = store.add(new TestToolBar(container, contextMenuProvider, { + responsiveBehavior: { + enabled: true, + kind: 'all', + minItems: 1, + actionMinWidth: 48, + getActionMinWidth: () => 22, + getAvailableWidth: () => 60, + }, + actionViewItemProvider: action => new FixedWidthActionViewItem(action, 22, !hiddenActionIds.has(action.id)), + })); + toolbar.setActions([ + store.add(new Action('hidden.a', 'Hidden A')), + store.add(new Action('hidden.b', 'Hidden B')), + store.add(new Action('visible.a', 'Visible A')), + store.add(new Action('hidden.c', 'Hidden C')), + store.add(new Action('visible.b', 'Visible B')), + ]); + + assert.deepStrictEqual({ + visibleActions: Array.from({ length: toolbar.getItemsLength() }, (_, index) => ({ + id: toolbar.getItemAction(index)?.id, + display: toolbar.getItemElement(index)?.style.display, + })).filter(item => item.display !== 'none').map(item => item.id), + overflow: toolbar.hasOverflow(), + }, { + visibleActions: ['visible.a', 'visible.b'], + overflow: false, + }); + }); + + test('can keep compact actions visible instead of overflowing', () => { + const toolbar = store.add(new TestToolBar(container, contextMenuProvider, { + responsiveBehavior: { + enabled: true, + kind: 'all', + minItems: 1, + actionMinWidth: 22, + getAvailableWidth: () => 50, + allowOverflow: false, + }, + actionViewItemProvider: action => new FixedWidthActionViewItem(action, 22), + })); + + toolbar.setActions([ + store.add(new Action('a', 'A')), + store.add(new Action('b', 'B')), + store.add(new Action('c', 'C')), + ]); + + assert.deepStrictEqual({ + items: Array.from({ length: toolbar.getItemsLength() }, (_, index) => toolbar.getItemAction(index)?.id), + overflow: toolbar.hasOverflow(), + }, { + items: ['a', 'b', 'c'], + overflow: false, + }); + }); + + test('allows overflow only after compact actions still exceed the width', () => { + let availableWidth = 100; + let allCompact = false; + const toolbar = store.add(new TestToolBar(container, contextMenuProvider, { + responsiveBehavior: { + enabled: true, + kind: 'all', + minItems: 1, + actionMinWidth: 22, + getAvailableWidth: () => availableWidth, + allowOverflow: () => allCompact, + }, + actionViewItemProvider: action => new FixedWidthActionViewItem(action, 22), + })); + toolbar.setActions([ + store.add(new Action('a', 'A')), + store.add(new Action('b', 'B')), + store.add(new Action('c', 'C')), + ]); + + availableWidth = 50; + toolbar.relayout(); + const beforeCompact = toolbar.hasOverflow(); + + allCompact = true; + toolbar.relayout(); + const afterCompact = toolbar.hasOverflow(); + + assert.deepStrictEqual({ beforeCompact, afterCompact }, { + beforeCompact: false, + afterCompact: true, + }); + }); + + test('uses overflow-specific proxy actions', async () => { + const runs: string[] = []; + let overflowAnchor: HTMLElement | undefined; + const toolbar = store.add(new TestToolBar(container, contextMenuProvider, { + responsiveBehavior: { + enabled: true, + kind: 'all', + minItems: 1, + actionMinWidth: 22, + getAvailableWidth: () => 50, + getOverflowAction: (action, getAnchor) => ({ + ...action, + run: () => { + overflowAnchor = getAnchor(); + runs.push(`overflow:${action.id}`); + }, + }), + }, + actionViewItemProvider: action => new FixedWidthActionViewItem(action, 22), + })); + toolbar.setActions([ + store.add(new Action('a', 'A', undefined, true, () => runs.push('original:a'))), + store.add(new Action('b', 'B', undefined, true, () => runs.push('original:b'))), + store.add(new Action('c', 'C', undefined, true, () => runs.push('original:c'))), + ]); + + const overflowAction = toolbar.getItemAction(toolbar.getItemsLength() - 1); + assert.strictEqual(overflowAction?.id, ToggleMenuAction.ID); + await (overflowAction as ToggleMenuAction).menuActions[0].run(); + const overflowViewItem = toolbar.getItemViewItem(toolbar.getItemsLength() - 1); + const overflowButton = overflowViewItem instanceof BaseActionViewItem ? overflowViewItem.element : undefined; + + assert.deepStrictEqual({ + runs, + usesOverflowButton: overflowAnchor === overflowButton, + }, { + runs: ['overflow:b'], + usesOverflowButton: true, + }); }); }); diff --git a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css index 043ab0c986d..137cb2c18fb 100644 --- a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css +++ b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css @@ -632,8 +632,7 @@ /* The chip row scrolls horizontally, so we never want to collapse labels * to icon-only — keep them visible regardless of viewport width. This - * overrides the desktop `@container (max-width: 330px)` query that - * hides `.sessions-chat-dropdown-label` to make icon-only chips. */ + * overrides the desktop collision-driven compact state. */ .agent-sessions-workbench.phone-layout .new-chat-widget-container .new-chat-bottom-container .action-label .sessions-chat-dropdown-label { display: inline; margin-left: 4px; diff --git a/src/vs/sessions/contrib/automations/browser/automationDialog.ts b/src/vs/sessions/contrib/automations/browser/automationDialog.ts index a2d569e5edb..80e2f1ebda2 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialog.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialog.ts @@ -444,6 +444,10 @@ export class AutomationIsolationGroupActionViewItem extends BaseActionViewItem { }); } + showPicker(anchor: HTMLElement): void { + this.branchPicker.showPicker(anchor); + } + private refreshTargetCapability(): void { const folderUri = this.isolationModel.folderUri; const sessionTypeId = this.state.sessionTypeId; @@ -1000,6 +1004,8 @@ export function renderForm( listForeground: 'var(--vscode-foreground)', listBackground: 'var(--vscode-input-background)', }; + let automationIsolationAction: IAction | undefined; + const overflowIsolationItem = disposables.add(new MutableDisposable()); const chatInputOptions: IChatInputPartOptions = { renderFollowups: false, @@ -1025,6 +1031,34 @@ export function renderForm( // leaving its scrollbar floating ~24px in from the right wall. inputPartHorizontalPadding: 0, sessionTypePickerDelegate: sessionTypeDelegate, + secondaryToolbarOverflowActionHandler: (actionId, anchor) => { + if (actionId === AUTOMATIONS_HARNESS_CHIP_ACTION_ID) { + sessionTypePicker.showPicker(anchor); + return true; + } + if (actionId === AUTOMATIONS_WORKSPACE_PICKER_ACTION_ID) { + workspacePicker.showPicker(false, anchor); + return true; + } + if (actionId === AUTOMATIONS_ISOLATION_GROUP_ACTION_ID && automationIsolationAction) { + const item = instantiationService.createInstance( + AutomationIsolationGroupActionViewItem, + automationIsolationAction, + state, + isolationModel, + isolationModel.folderUriObs, + onDidChangeSessionTarget.event, + revalidate, + undefined, + workspaceControlsVisible, + ); + overflowIsolationItem.value = item; + item.render(DOM.$('.automation-overflow-isolation-picker')); + item.showPicker(anchor); + return true; + } + return false; + }, secondaryToolbarActionViewItemProvider: (action, itemOptions) => { if (action.id === AUTOMATIONS_HARNESS_CHIP_ACTION_ID) { return new AutomationPickerActionViewItem(action, container => sessionTypePicker.render(container), undefined, itemOptions); @@ -1036,6 +1070,7 @@ export function renderForm( }, undefined, itemOptions); } if (action.id === AUTOMATIONS_ISOLATION_GROUP_ACTION_ID) { + automationIsolationAction = action; const item = instantiationService.createInstance( AutomationIsolationGroupActionViewItem, action, diff --git a/src/vs/sessions/contrib/chat/browser/branchPicker.ts b/src/vs/sessions/contrib/chat/browser/branchPicker.ts index 8d81aa26490..9368cad17cd 100644 --- a/src/vs/sessions/contrib/chat/browser/branchPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/branchPicker.ts @@ -201,8 +201,8 @@ export class BranchPicker extends Disposable { } } - showPicker(): void { - if (!this._triggerElement || this._actionWidgetService.isVisible || !this._state.canOpen) { + showPicker(anchor = this._triggerElement): void { + if (!anchor || this._actionWidgetService.isVisible || !this._state.canOpen) { return; } @@ -218,15 +218,15 @@ export class BranchPicker extends Disposable { }, onHide: () => { this._isOpen = false; - trigger.setAttribute('aria-expanded', 'false'); - if (trigger.isConnected) { + trigger?.setAttribute('aria-expanded', 'false'); + if (trigger?.isConnected) { trigger.focus(); } }, }; this._isOpen = true; - trigger.setAttribute('aria-expanded', 'true'); + trigger?.setAttribute('aria-expanded', 'true'); const items = this._getItems(); const branchCount = items.filter(item => item.item?.kind === 'branch' && !item.item.unavailable).length; this._actionWidgetService.show( @@ -234,7 +234,7 @@ export class BranchPicker extends Disposable { false, items, delegate, - trigger, + anchor, undefined, [], { diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css index acd5300f2a0..2e47a616ae6 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -175,10 +175,6 @@ color: var(--vscode-icon-foreground); } -.sessions-chat-toolbar-spacer { - flex: 1; -} - /* Voice mode controls (mic / stop / settings / disconnect) */ .sessions-chat-voice-toolbar { display: flex; @@ -228,6 +224,7 @@ .sessions-chat-config-toolbar { display: flex; align-items: center; + flex: 1 1 0; min-width: 0; overflow: hidden; } @@ -248,12 +245,35 @@ display: flex; align-items: center; min-width: 30px; - overflow: hidden; + overflow: visible; } -/* Prevent the mode picker from shrinking so the model picker label - * ellipsizes first rather than the mode picker collapsing to icon-only. */ -.sessions-chat-config-toolbar .monaco-action-bar .action-item:has(.sessions-chat-dropdown-label) { +.sessions-chat-config-toolbar .monaco-action-bar .action-item.compact-picker { + box-sizing: border-box; + width: 22px; + min-width: 22px; + padding: 0; +} + +.sessions-chat-config-toolbar .monaco-action-bar .action-item.compact-picker .action-label { + box-sizing: border-box; + width: 22px; + min-width: 22px; + padding: 2px 2px 2px 8px; + justify-content: flex-start; +} + +.sessions-chat-config-toolbar .monaco-action-bar .action-item.compact-picker .action-label.model-picker-split { + padding: 0; +} + +.sessions-chat-config-toolbar .monaco-action-bar .action-item.compact-picker .chat-input-picker-label { + display: none; +} + +/* Expanded pickers remain intrinsic; the responsive controller switches them + * to compact form instead of allowing their labels to truncate. */ +.sessions-chat-config-toolbar .monaco-action-bar .action-item:not(.compact-picker) { flex-shrink: 0; } @@ -269,7 +289,7 @@ color: var(--vscode-icon-foreground); white-space: nowrap; min-width: 30px; - overflow: hidden; + overflow: visible; } .sessions-chat-config-toolbar .action-label:hover { @@ -282,13 +302,13 @@ font-size: var(--vscode-agents-fontSize-label2, 11px); } -/* Allow long labels (e.g. the model picker name) to ellipsize when space is tight */ +/* Expanded labels are never truncated; compact mode removes the label. */ .sessions-chat-config-toolbar .action-label .chat-input-picker-label { margin-left: 4px; - overflow: hidden; - text-overflow: ellipsis; + flex-shrink: 0; + overflow: visible; + text-overflow: clip; white-space: nowrap; - min-width: 0; } /* When the picker has no leading icon (e.g. model picker), drop the icon-to-label gap. */ @@ -655,4 +675,3 @@ .sessions-chat-attachment-remove:hover { background-color: var(--vscode-toolbar-hoverBackground); } - diff --git a/src/vs/sessions/contrib/chat/browser/media/chatView.css b/src/vs/sessions/contrib/chat/browser/media/chatView.css index d34de574077..4fbd4ed3c6c 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatView.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatView.css @@ -242,3 +242,7 @@ .agent-sessions-workbench .interactive-session .chat-input-toolbars .chat-sessionPicker-container { display: none; } + +.agent-sessions-workbench .interactive-session .compact-picker .sessions-chat-dropdown-label { + display: none; +} diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 80a3b1fa517..792d63c35a5 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -23,9 +23,6 @@ box-sizing: border-box; overflow: hidden; padding: 16px 16px 20px 16px; - /* Establishes a size container so the @container (max-width: 330px) query below - * can collapse picker labels to icon-only when the new-chat area is narrow. */ - container-type: size; position: relative; } @@ -113,6 +110,10 @@ display: flex; } +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container { + overflow: hidden; +} + .new-chat-widget-container .new-chat-bottom-container .new-chat-controls-container { display: flex; gap: 2px; @@ -133,64 +134,63 @@ overflow: hidden; } -/* Allow nested toolbar items to shrink so labels can ellipsize when space is tight. - * Mirrors the regular chat-input-toolbar pattern: each flex layer between the - * bounded container and the ellipsizing label gets `min-width: 0; overflow: hidden`. */ +/* Toolbar hosts can shrink, while individual expanded pickers remain intrinsic + * and switch to compact form before their labels would truncate. */ .new-chat-widget-container .new-chat-bottom-container .new-chat-controls-container > *, -.new-chat-widget-container .new-chat-bottom-container .new-chat-repo-config-container > *, -.new-chat-widget-container .new-chat-bottom-container .sessions-chat-picker-slot .action-label { +.new-chat-widget-container .new-chat-bottom-container .new-chat-repo-config-container > * { min-width: 0; overflow: hidden; } -/* Floor each picker so the icon + chevron (+ padding) stay visible even when - * the label is fully ellipsized. Approx: 7px padding-left + 12px icon + 2px - * label margin + 16px chevron box + 1px padding-right ~= 38px. The floor must - * be applied to the outermost flex item (.action-item), not just the label, - * because the parent's `min-width: 0` would otherwise let it clip the chevron. */ -.new-chat-widget-container .new-chat-bottom-container .monaco-action-bar .action-item, -.new-chat-widget-container .new-chat-bottom-container .sessions-chat-picker-slot .action-label, -.new-chat-widget-container .new-chat-bottom-container .monaco-action-bar .action-item .action-label { +/* Expanded picker controls never shrink or ellipsize. */ +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .monaco-action-bar .action-item:not(.compact-picker), +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .sessions-chat-picker-slot:not(.compact-picker), +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .sessions-chat-picker-slot:not(.compact-picker) .action-label { + flex-shrink: 0; min-width: 30px; - overflow: hidden; + overflow: visible; } -/* Below this width the bottom-row pickers can't fit their labels comfortably, - * so collapse to icon + chevron only. The .new-chat-widget-container declares - * `container-type: size` which makes this a size container query. The - * permission picker (`.sessions-chat-permission-picker`) gets a more lenient - * threshold below because its label ("Autopilot (Preview)" etc.) carries - * important state that is worth preserving as long as there is room. */ -@container (max-width: 330px) { - /* Bottom-row pickers (Copilot CLI, Default Permissions, Worktree, branch): icon-only */ - .new-chat-widget-container .new-chat-bottom-container .sessions-chat-dropdown-label { - display: none; - } - - .new-chat-widget-container .new-chat-bottom-container .sessions-chat-permission-picker .sessions-chat-dropdown-label { - display: revert; - } - - /* Chat input config toolbar: hide mode picker label (uses sessions-chat-dropdown-label), - * but keep the model picker label (uses chat-input-picker-label) visible. */ - .new-chat-widget-container .sessions-chat-config-toolbar .sessions-chat-dropdown-label { - display: none; - } - - /* With both chevron and label hidden the only content is the icon. Center - * it instead of leaving the 30px min-width as left-aligned padding. - * Permission picker keeps its label so its action-item is excluded. */ - .new-chat-widget-container .new-chat-bottom-container .monaco-action-bar .action-item:not(.sessions-chat-permission-picker) .action-label, - .new-chat-widget-container .new-chat-bottom-container .sessions-chat-picker-slot:not(.sessions-chat-permission-picker) .action-label { - justify-content: center; - padding: 3px; - } +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .sessions-chat-picker-slot:not(.compact-picker) .sessions-chat-dropdown-label, +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .sessions-chat-picker-slot:not(.compact-picker) .chat-session-option-label { + flex-shrink: 0; + overflow: visible; + text-overflow: clip; + white-space: nowrap; } -@container (max-width: 240px) { - .new-chat-widget-container .new-chat-bottom-container .sessions-chat-permission-picker .sessions-chat-dropdown-label { - display: none; - } +/* Individual picker controls collapse from right to left as their row runs out of room. */ +.new-chat-widget-container .compact-picker .sessions-chat-dropdown-label { + display: none; +} + +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .compact-picker.action-item, +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .compact-picker.sessions-chat-picker-slot { + box-sizing: border-box; + width: 22px; + min-width: 22px; + padding: 0; +} + +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .compact-picker.action-item .action-label, +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .compact-picker.sessions-chat-picker-slot .action-label { + box-sizing: border-box; + width: 22px; + min-width: 22px; + justify-content: flex-start; + padding: 2px 2px 2px 8px; +} + +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .compact-picker.sessions-chat-picker-slot .action-label > .codicon { + width: var(--vscode-codiconFontSize-compact); + height: var(--vscode-codiconFontSize-compact); + line-height: var(--vscode-codiconFontSize-compact); +} + +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .compact-picker.sessions-chat-checkbox-chip .monaco-checkbox { + width: 12px; + height: 12px; + margin-right: 0; } /* Spacing between action items inside the bottom-row toolbars (e.g. Worktree, branch) */ diff --git a/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts index c0815437da1..32a38a0dd94 100644 --- a/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts @@ -66,12 +66,12 @@ export class MobileSessionTypePicker extends SessionTypePicker { super.render(container, options); } - protected override _showPicker(): void { - if (!this._triggerElement) { + protected override _showPicker(anchor = this._triggerElement): void { + if (!anchor) { return; } if (!isPhoneLayout(this.layoutService)) { - super._showPicker(); + super._showPicker(anchor); return; } if (this._folderSessionTypes.length <= 1 && this._pickServedByFolder(this._picked)) { @@ -114,6 +114,9 @@ export class MobileSessionTypePicker extends SessionTypePicker { } const trigger = this._triggerElement; + if (!trigger) { + return; + } trigger.setAttribute('aria-expanded', 'true'); showMobilePickerSheet( this.layoutService.mainContainer, diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 6c140554537..a10591cc039 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -89,6 +89,7 @@ import { ChatInputNotificationWidget } from '../../../../workbench/contrib/chat/ import { ChatInputNoticeHost, ChatInputNoticeLane } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputNoticeHost.js'; import { registerChatInputOnboardingHosts } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputOnboardingHosts.js'; import { IChatInputNoticeHubService } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputNoticeHub.js'; +import { ChatInputPickerResponsiveLayout, IChatInputPickerResponsiveLayoutItem } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.js'; import { chatInputStackClass, chatInputStackSlotClass, ChatInputStackSlot, refreshChatInputStack, setChatInputStackSlot } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputStack.js'; import { IChatSubmitRequestHandlerService } from '../../../../workbench/contrib/chat/browser/chatSubmitRequestHandlerService.js'; import { INewChatModelPickerService, NewChatModelPickerService } from './newChatModelPicker.js'; @@ -131,6 +132,41 @@ const MIN_EDITOR_HEIGHT = 50; const MAX_EDITOR_HEIGHT = 200; const NEW_CHAT_INPUT_FONT_FAMILY = 'system-ui, -apple-system, sans-serif'; +function getLabeledPickerResponsiveItems(container: HTMLElement): IChatInputPickerResponsiveLayoutItem[] { + const elements = new Map(); + const actionItemLabelCounts = new Map(); + const visit = (element: HTMLElement, pickerSlot: HTMLElement | undefined, actionItem: HTMLElement | undefined): void => { + const currentPickerSlot = element.classList.contains('sessions-chat-picker-slot') ? element : pickerSlot; + const currentActionItem = element.classList.contains('action-item') ? element : actionItem; + if (element.classList.contains('sessions-chat-dropdown-label')) { + const pickerElement = currentPickerSlot ?? currentActionItem; + if (pickerElement) { + elements.set(pickerElement, currentActionItem); + if (currentActionItem) { + actionItemLabelCounts.set(currentActionItem, (actionItemLabelCounts.get(currentActionItem) ?? 0) + 1); + } + } + } + for (const child of element.children) { + if (dom.isHTMLElement(child)) { + visit(child, currentPickerSlot, currentActionItem); + } + } + }; + visit(container, undefined, undefined); + + return Array.from(elements, ([element, actionItem]) => ({ + element, + isCompact: () => element.classList.contains('compact-picker'), + setCompact: compact => { + element.classList.toggle('compact-picker', compact); + if (actionItem && actionItem !== element && actionItemLabelCounts.get(actionItem) === 1) { + actionItem.classList.toggle('compact-picker', compact); + } + }, + })); +} + /** True while focus is in an Agents window composer that supports dictation. */ const SessionsChatInputHasDictationFocus = new RawContextKey('sessionsChatInputHasDictationFocus', false, localize('sessionsChatInputHasDictationFocus', "True when focus is in an Agents window chat composer that supports dictation.")); @@ -312,7 +348,6 @@ function getRandomChatInputPlaceholder(): string { // #region --- New Chat Widget --- export class NewChatInputWidget extends Disposable implements IHistoryNavigationWidget, INewSessionComposer { - private static readonly compactModelPickerWidth = 280; readonly sessionTypePicker: SessionTypePicker; @@ -384,6 +419,8 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation private readonly _modelSelection: SessionModelSelection; private readonly _canSendRequest: IObservable; private readonly _compactModelPicker = observableValue(this, false); + private _primaryPickerResponsiveLayout: ChatInputPickerResponsiveLayout | undefined; + private _secondaryPickerResponsiveLayout: ChatInputPickerResponsiveLayout | undefined; // Input state private _draftState: IDraftState | undefined = { @@ -650,6 +687,11 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation }, })); + this._secondaryPickerResponsiveLayout = this._register(new ChatInputPickerResponsiveLayout('NewChatInput.secondaryPicker', newChatBottomContainer, { + getItems: () => getLabeledPickerResponsiveItems(newChatBottomContainer), + })); + this._secondaryPickerResponsiveLayout.layout(); + // Restore draft input state from storage this._restoreState(); @@ -967,7 +1009,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation // Session config pickers (such as model) — rendered via MenuWorkbenchToolBar // Visibility controlled by context keys (isActiveSessionBackgroundProvider, isNewChatSession) const configContainer = dom.append(toolbar, dom.$('.sessions-chat-config-toolbar')); - this._register(this._scopedInstantiationService.createInstance(MenuWorkbenchToolBar, configContainer, Menus.NewSessionConfig, { + const configToolbar = this._register(this._scopedInstantiationService.createInstance(MenuWorkbenchToolBar, configContainer, Menus.NewSessionConfig, { hiddenItemStrategy: HiddenItemStrategy.NoHide, actionViewItemProvider: (action) => { if (action.id === 'sessions.modelPicker') { @@ -978,8 +1020,6 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation }, })); - dom.append(toolbar, dom.$('.sessions-chat-toolbar-spacer')); - // Dictation mic button. Shares the STT service, mic // device, and gating (backend support + `dictation.enabled`) // with the main chat input; inserts the transcript into this composer's @@ -1043,6 +1083,32 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation this._register(sendButton.onDidClick(e => this._send(!!this.options.supportsBackground && !!(e as MouseEvent | KeyboardEvent | undefined)?.altKey))); } updateVoiceInputActionBorder(); + + this._primaryPickerResponsiveLayout = this._register(new ChatInputPickerResponsiveLayout('NewChatInput.primaryPicker', configContainer, { + getItems: () => { + const items: IChatInputPickerResponsiveLayoutItem[] = []; + for (let index = 0; index < configToolbar.getItemsLength(); index++) { + const element = configToolbar.getItemElement(index); + if (!element) { + continue; + } + items.push({ + element, + isCompact: () => element.classList.contains('compact-picker'), + setCompact: (compact: boolean) => { + element.classList.toggle('compact-picker', compact); + if (configToolbar.getItemAction(index)?.id === 'sessions.modelPicker') { + this._compactModelPicker.set(compact, undefined); + } + }, + }); + } + return items; + }, + hasOverflow: () => configToolbar.hasOverflow(), + relayout: () => configToolbar.relayout(), + })); + this._primaryPickerResponsiveLayout.layout(); } private _createVoiceInputModePill(toolbar: HTMLElement, inputContainer: HTMLElement): void { @@ -1435,9 +1501,10 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation } } - layout(_height: number, width: number): void { - this._compactModelPicker.set(width < NewChatInputWidget.compactModelPickerWidth, undefined); + layout(_height: number, _width: number): void { this._editor?.layout(); + this._primaryPickerResponsiveLayout?.layout(); + this._secondaryPickerResponsiveLayout?.layout(); } focus(): void { diff --git a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts index 76f8116e367..48fb40478df 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts @@ -401,8 +401,12 @@ export class SessionTypePicker extends Disposable { * the override can decide where to anchor (or that it doesn't need * anchoring at all, e.g. for a bottom sheet). */ - protected _showPicker(): void { - if (!this._triggerElement || this.actionWidgetService.isVisible) { + showPicker(anchor?: HTMLElement): void { + this._showPicker(anchor); + } + + protected _showPicker(anchor = this._triggerElement): void { + if (!anchor || this.actionWidgetService.isVisible) { return; } @@ -498,7 +502,11 @@ export class SessionTypePicker extends Disposable { this.actionWidgetService.hide(); this._handleSelectedSessionType(item); }, - onHide: () => { triggerElement.focus(); }, + onHide: () => { + if (triggerElement?.isConnected) { + triggerElement.focus(); + } + }, }; this.actionWidgetService.show( @@ -506,7 +514,7 @@ export class SessionTypePicker extends Disposable { false, groupedItems, delegate, - this._triggerElement, + anchor, undefined, [], { diff --git a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts index 002d5052530..ed387797ce5 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts @@ -43,6 +43,73 @@ suite('Sessions - Chat View', () => { assert.deepStrictEqual({ forwarded, petHostVisible: isVisible.get() }, { forwarded: [false, true], petHostVisible: true }); }); + test('hides the phone combined picker label when compact', () => { + const toolbar = dom.append(document.body, dom.$('.sessions-chat-config-toolbar')); + disposables.add(toDisposable(() => toolbar.remove())); + const actionBar = dom.append(toolbar, dom.$('.monaco-action-bar')); + const item = dom.append(actionBar, dom.$('.action-item.compact-picker')); + const label = dom.append(item, dom.$('.chat-input-picker-label')); + + assert.strictEqual(dom.getWindow(label).getComputedStyle(label).display, 'none'); + }); + + test('keeps compact empty-state picker icons inside their action item', () => { + const toolbar = dom.append(document.body, dom.$('.sessions-chat-config-toolbar')); + disposables.add(toDisposable(() => toolbar.remove())); + const actionBar = dom.append(toolbar, dom.$('.monaco-action-bar')); + const item = dom.append(actionBar, dom.$('.action-item.compact-picker')); + const label = dom.append(item, dom.$('a.action-label')); + const icon = dom.append(label, dom.$('span.codicon')); + icon.style.width = '12px'; + icon.style.height = '12px'; + + const itemBounds = item.getBoundingClientRect(); + const labelBounds = label.getBoundingClientRect(); + const iconBounds = icon.getBoundingClientRect(); + assert.deepStrictEqual({ + labelOffset: labelBounds.left - itemBounds.left, + iconOffset: iconBounds.left - itemBounds.left, + iconEscapes: iconBounds.left < itemBounds.left || iconBounds.right > itemBounds.right, + }, { + labelOffset: 0, + iconOffset: 8, + iconEscapes: false, + }); + }); + + test('keeps compact bottom-row picker glyphs inside their action item', () => { + const workbench = dom.append(document.body, dom.$('.agent-sessions-workbench')); + disposables.add(toDisposable(() => workbench.remove())); + workbench.style.setProperty('--vscode-codiconFontSize-compact', '12px'); + const widget = dom.append(workbench, dom.$('.new-chat-widget-container.revealed')); + const row = dom.append(widget, dom.$('.new-chat-bottom-container')); + const actionBar = dom.append(row, dom.$('.monaco-action-bar')); + const item = dom.append(actionBar, dom.$('.action-item.compact-picker')); + const label = dom.append(item, dom.$('a.action-label')); + const icon = dom.append(label, dom.$('span.codicon')); + icon.style.width = '12px'; + icon.style.height = '12px'; + + const itemBounds = item.getBoundingClientRect(); + const labelBounds = label.getBoundingClientRect(); + const iconBounds = icon.getBoundingClientRect(); + assert.deepStrictEqual({ + itemWidth: itemBounds.width, + labelWidth: labelBounds.width, + labelOffset: labelBounds.left - itemBounds.left, + iconWidth: iconBounds.width, + iconOffset: iconBounds.left - itemBounds.left, + iconEscapes: iconBounds.left < itemBounds.left || iconBounds.right > itemBounds.right, + }, { + itemWidth: 22, + labelWidth: 22, + labelOffset: 0, + iconWidth: 12, + iconOffset: 8, + iconEscapes: false, + }); + }); + test('does not forward aquarium visibility to the peer chat composer', () => { const isVisible = observableValue(disposables, true); const view: NewChatView = Object.assign(Object.create(NewChatView.prototype), { diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts index 1355c07f504..24934ecbc7a 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts @@ -107,10 +107,6 @@ class TestSessionTypePicker extends SessionTypePicker { pick(p: IPickedSessionType): void { this._handleSelectedSessionType(p); } - - showPicker(): void { - this._showPicker(); - } } function createPicker( diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerActionItem.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerActionItem.ts index 06779871f11..1453a8aa8b7 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerActionItem.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerActionItem.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { autorun, IObservable } from '../../../../../base/common/observable.js'; +import { autorun, IObservable, ISettableObservable } from '../../../../../base/common/observable.js'; import { MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -16,6 +16,7 @@ import { IOpenerService } from '../../../../../platform/opener/common/opener.js' import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IChatInputPickerOptions } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; +import { IChatInputPickerResponsiveState } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.js'; import { PermissionPickerActionItem } from '../../../../../workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { AgentHostPermissionPickerDelegate } from './agentHostPermissionPickerDelegate.js'; @@ -28,13 +29,14 @@ import { AgentHostPermissionPickerDelegate } from './agentHostPermissionPickerDe * the active session's `autoApprove` schema doesn't match the well-known * shape. */ -export class AgentHostPermissionPickerActionItem extends PermissionPickerActionItem { +export class AgentHostPermissionPickerActionItem extends PermissionPickerActionItem implements IChatInputPickerResponsiveState { private readonly _delegate: AgentHostPermissionPickerDelegate; + private readonly _compact: ISettableObservable; constructor( action: MenuItemAction, - pickerOptions: IChatInputPickerOptions, + pickerOptions: IChatInputPickerOptions & { readonly compact: ISettableObservable }, session: IObservable, @IInstantiationService instantiationService: IInstantiationService, @IActionWidgetService actionWidgetService: IActionWidgetService, @@ -63,6 +65,7 @@ export class AgentHostPermissionPickerActionItem extends PermissionPickerActionI hoverService, ); this._delegate = this._register(delegate); + this._compact = pickerOptions.compact; // The base widget's label is rendered on demand via `refresh()`. Keep it // in sync with the delegate's level observable. @@ -72,6 +75,14 @@ export class AgentHostPermissionPickerActionItem extends PermissionPickerActionI })); } + isCompact(): boolean { + return this._compact.get(); + } + + setCompact(compact: boolean): void { + this._compact.set(compact, undefined); + } + override render(container: HTMLElement): void { super.render(container); // The active session can change while this view item is alive (the diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts index c51574db35d..eb0b7d99eea 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts @@ -14,7 +14,7 @@ import { Checkbox } from '../../../../../base/browser/ui/toggle/toggle.js'; import { Delayer } from '../../../../../base/common/async.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; -import { autorun, constObservable, IObservable } from '../../../../../base/common/observable.js'; +import { autorun, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { localize, localize2 } from '../../../../../nls.js'; import { IActionViewItemService, type IActionViewItemFactory } from '../../../../../platform/actions/browser/actionViewItemService.js'; @@ -34,6 +34,7 @@ import { ChatContextKeyExprs, ChatContextKeys } from '../../../../../workbench/c import { markOnboardingTarget } from '../../../../../workbench/contrib/onboarding/browser/spotlight/onboardingTarget.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; import { type IChatInputPickerOptions } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; +import { IChatInputPickerResponsiveState } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.js'; import { Menus } from '../../../../browser/menus.js'; import { SessionProviderIdContext, IsPhoneLayoutContext, IsQuickChatSessionContext } from '../../../../common/contextkeys.js'; import { IWorkbenchLayoutService } from '../../../../../workbench/services/layout/browser/layoutService.js'; @@ -989,9 +990,12 @@ class MobileAgentHostSessionConfigPicker extends AgentHostSessionConfigPicker { interface IConfigPickerWidget extends IDisposable { render(container: HTMLElement): void; + showPicker?(anchor: HTMLElement, onHide?: () => void): boolean | void; } -export class PickerActionViewItem extends BaseActionViewItem { +export class PickerActionViewItem extends BaseActionViewItem implements IChatInputPickerResponsiveState { + private _compact = false; + constructor(private readonly _picker: IConfigPickerWidget, disposable?: IDisposable) { super(undefined, { id: '', label: '', enabled: true, class: undefined, tooltip: '', run: () => { } }); if (disposable) { @@ -1000,7 +1004,25 @@ export class PickerActionViewItem extends BaseActionViewItem { } override render(container: HTMLElement): void { + this.element = container; this._picker.render(container); + container.classList.toggle('compact-picker', this._compact); + } + + isCompact(): boolean { + return this._compact; + } + + setCompact(compact: boolean): void { + this._compact = compact; + this.element?.classList.toggle('compact-picker', compact); + } + + show(anchor?: HTMLElement): void { + const target = anchor ?? this.element; + if (target) { + this._picker.showPicker?.(target); + } } override dispose(): void { @@ -1126,10 +1148,10 @@ class AgentHostSessionConfigPickerContribution extends Disposable implements IWo return undefined; } const { session } = instantiationService.invokeFunction(accessor => accessor.get(ISessionContext)); - const pickerOptions: IChatInputPickerOptions = { - compact: constObservable(true), + const pickerOptions = { + compact: observableValue(action, false), listOptions: { minWidth: 255 }, - }; + } satisfies IChatInputPickerOptions; return instantiationService.createInstance( AgentHostPermissionPickerActionItem, action, diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts index 678d46f3025..918cc40e166 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts @@ -27,7 +27,7 @@ import { IAgentHostSessionsProvider, LOCAL_AGENT_HOST_PROVIDER_ID } from '../../ import { ISessionsProvidersService } from '../../../../../../services/sessions/browser/sessionsProvidersService.js'; import { IActiveSession } from '../../../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvider } from '../../../../../../services/sessions/common/sessionsProvider.js'; -import { AgentHostSessionConfigPicker, IConfigPickerItem } from '../../../browser/agentHostSessionConfigPicker.js'; +import { AgentHostSessionConfigPicker, IConfigPickerItem, PickerActionViewItem } from '../../../browser/agentHostSessionConfigPicker.js'; const SESSION_ID = 'local-agent-host:s1'; @@ -231,6 +231,38 @@ suite('Agent Host Session Config Picker', () => { }); }); + test('picker action view items expose responsive compact state', () => { + let pickerAnchor: HTMLElement | undefined; + const item = store.add(new PickerActionViewItem({ + render: () => { }, + showPicker: anchor => { + pickerAnchor = anchor; + return true; + }, + dispose: () => { }, + })); + const container = document.createElement('div'); + const overflowAnchor = document.createElement('button'); + item.render(container); + const expanded = { + compact: item.isCompact(), + className: container.classList.contains('compact-picker'), + }; + + item.setCompact(true); + item.show(overflowAnchor); + const compact = { + compact: item.isCompact(), + className: container.classList.contains('compact-picker'), + usesOverflowAnchor: pickerAnchor === overflowAnchor, + }; + + assert.deepStrictEqual({ expanded, compact }, { + expanded: { compact: false, className: false }, + compact: { compact: true, className: true, usesOverflowAnchor: true }, + }); + }); + test('a picker recreated on a session switch still renders the provider-seeded chips (disabled) while resolving', () => { const services = setupServices(store); const { provider } = services; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts index 7e178fa86a7..fbf3b88f73a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts @@ -416,6 +416,10 @@ export class AgentHostChatInputPicker extends Disposable { this._renderChip(); } + show(anchor: HTMLElement): void { + void this._showPicker(anchor); + } + private _reattach(): void { const sessionResource = this._widget.viewModel?.sessionResource; const provisionalBackend = sessionResource ? this._provisional.get(sessionResource) : undefined; @@ -504,6 +508,7 @@ export class AgentHostChatInputPicker extends Disposable { this._trigger = undefined; this._renderDisposables.clear(); dom.clearNode(this._container); + this._container.classList.remove('agent-host-chat-input-picker-has-icon'); const ctx = this._readContext(); // For sessions that have already started (i.e. no longer untitled — @@ -548,6 +553,7 @@ export class AgentHostChatInputPicker extends Disposable { dom.clearNode(trigger); const icon = getConfigIcon(this._property, value); + this._container?.classList.toggle('agent-host-chat-input-picker-has-icon', !!icon); if (icon) { dom.append(trigger, renderIcon(getCompactCodicon(icon))); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts index 20aa5937d84..995eb823660 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts @@ -150,15 +150,18 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt const selected = this._selectedFolder(); const folder = selected && this._workspaceContextService.getWorkspace().folders.find(f => f.uri.toString() === selected.toString()); const label = folder ? folder.name : (selected ? basename(selected) : localize('agentHost.selectFolder', "Folder")); + const compact = this.pickerOptions.compact.get(); + element.classList.toggle('icon-only', compact); dom.reset( element, ...renderLabelWithIcons(`$(folder-compact)`), - dom.$('span.chat-input-picker-label', undefined, label), + ...(!compact ? [dom.$('span.chat-input-picker-label', undefined, label)] : []), ); // Set the aria label after the visible text is in place: the base class // derives it from `element.textContent`, so labeling first would lag one // selection behind. this.setAriaLabelAttributes(element); + element.ariaLabel = label; return null; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts index 7130daf9d29..89c890813d6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts @@ -37,6 +37,7 @@ export class AgentHostGenericConfigChips extends Disposable { private _container: HTMLElement | undefined; private readonly _chips = this._register(new DisposableMap()); + private readonly _chipElements = new Map(); /** * Subscription to the active session's backend state. Maintained for the @@ -76,6 +77,10 @@ export class AgentHostGenericConfigChips extends Disposable { this._sync(); } + getCompactableElements(): readonly HTMLElement[] { + return Array.from(this._chipElements.values()).filter(element => element.classList.contains('agent-host-chat-input-picker-has-icon')); + } + private _reattach(): void { const sessionResource = this._widget.viewModel?.sessionResource; const provisionalBackend = sessionResource ? this._provisional.get(sessionResource) : undefined; @@ -186,6 +191,7 @@ export class AgentHostGenericConfigChips extends Disposable { for (const property of [...this._chips.keys()]) { if (!desired.has(property)) { this._chips.deleteAndDispose(property); + this._chipElements.delete(property); } } @@ -201,10 +207,12 @@ export class AgentHostGenericConfigChips extends Disposable { // in `chat.css` (height, padding, chevron) applies here too. const slot = dom.append(this._container, dom.$('.agent-host-generic-chip-slot.chat-input-picker-item')); chip.render(slot); + this._chipElements.set(property, slot); this._chips.set(property, { dispose: () => { chip.dispose(); slot.remove(); + this._chipElements.delete(property); }, }); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css index 509d6325322..58e754bf226 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css @@ -90,15 +90,22 @@ } -/* Collapse agent host picker labels to icon-only when the secondary toolbar gets narrow. */ -.interactive-session .chat-secondary-toolbar { - container-type: inline-size; +/* Individual secondary pickers collapse from right to left as the lane narrows. */ +.interactive-session .compact-picker .agent-host-chat-input-picker-label { + display: none; } -@container (max-width: 350px) { - .agent-host-chat-input-picker-label { - display: none; - } +.interactive-session .compact-picker .agent-host-chat-input-picker-slot .action-label { + box-sizing: border-box; + width: 22px; + min-width: 22px; + padding: 2px 2px 2px 8px; + justify-content: flex-start; +} + +.interactive-session .compact-picker .agent-host-chat-input-picker-slot .action-label .codicon { + width: auto; + height: auto; } /* @@ -132,9 +139,9 @@ } .agent-host-chat-input-picker-label { - max-width: 16em; - overflow: hidden; - text-overflow: ellipsis; + flex-shrink: 0; + overflow: visible; + text-overflow: clip; white-space: nowrap; } diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts index dae44597c96..5f67c484c53 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts @@ -238,19 +238,24 @@ export class ChatSessionPickerActionItem extends ActionWidgetDropdownActionViewI const domChildren = []; element.classList.add('chat-session-option-picker'); const group = this.delegate.getOptionGroup(); + const compact = this._pickerOptions?.compact.get() ?? false; + element.classList.toggle('compact', compact); + const label = this.currentOption?.name ?? group?.description ?? localize('chat.sessionPicker.label', "Pick Option"); // If the current option is the default and has an icon, collapse the text and show only the icon const isDefaultWithIcon = this.currentOption?.default && this.currentOption?.icon; + element.classList.toggle('icon-only', compact && !!this.currentOption?.icon); if (this.currentOption?.icon) { domChildren.push(renderIcon(getCompactCodicon(this.currentOption.icon))); } - if (!isDefaultWithIcon) { - domChildren.push(dom.$('span.chat-session-option-label', undefined, this.currentOption?.name ?? group?.description ?? localize('chat.sessionPicker.label', "Pick Option"))); + if (!isDefaultWithIcon && (!compact || !this.currentOption?.icon)) { + domChildren.push(dom.$('span.chat-session-option-label', undefined, label)); } dom.reset(element, ...domChildren); this.setAriaLabelAttributes(element); + element.ariaLabel = label; return null; } 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 5e9c71cc7e3..dcf156f2097 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -30,7 +30,7 @@ import { ResourceSet } from '../../../../../../base/common/map.js'; import { MarshalledId } from '../../../../../../base/common/marshallingIds.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { mixin } from '../../../../../../base/common/objects.js'; -import { autorun, constObservable, derived, derivedOpts, IObservable, ISettableObservable, ITransaction, observableFromEvent, observableValue, transaction } from '../../../../../../base/common/observable.js'; +import { autorun, derived, derivedOpts, IObservable, ISettableObservable, ITransaction, observableFromEvent, observableValue, transaction } from '../../../../../../base/common/observable.js'; import { isMacintosh } from '../../../../../../base/common/platform.js'; import { isEqual } from '../../../../../../base/common/resources.js'; import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; @@ -57,6 +57,7 @@ import { SuggestController } from '../../../../../../editor/contrib/suggest/brow import { localize } from '../../../../../../nls.js'; import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; import { MenuWorkbenchButtonBar } from '../../../../../../platform/actions/browser/buttonbar.js'; +import { IActionViewItemService, type IActionViewItemFactory } from '../../../../../../platform/actions/browser/actionViewItemService.js'; import { MenuEntryActionViewItem } from '../../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; import { MenuId, MenuItemAction } from '../../../../../../platform/actions/common/actions.js'; @@ -160,6 +161,7 @@ import { ChatInputNoticeHost, ChatInputNoticeLane } from './chatInputNoticeHost. import { registerChatInputOnboardingHosts } from './chatInputOnboardingHosts.js'; import { IChatInputNoticeHubService } from './chatInputNoticeHub.js'; import { IChatInputPickerOptions } from './chatInputPickerActionItem.js'; +import { ChatInputPickerResponsiveLayout, IChatInputPickerResponsiveLayoutItem, isChatInputPickerResponsiveState } from './chatInputPickerResponsiveLayout.js'; import { chatInputStackClass, chatInputStackSlotClass, ChatInputStackSlot, setChatInputStackInputFocused, setChatInputStackSlot } from './chatInputStack.js'; import { ChatSelectedTools } from './chatSelectedTools.js'; import { ChatPetAchievementIds, didExplicitlySwitchChatPetModel } from '../../chatPetAchievements.js'; @@ -182,9 +184,64 @@ const INPUT_EDITOR_MAX_HEIGHT = 250; const INPUT_EDITOR_LINE_HEIGHT = 20; const INPUT_EDITOR_PADDING = { compact: { top: 2, bottom: 2 }, default: { top: 12, bottom: 12 } }; const CachedLanguageModelsKey = 'chat.cachedLanguageModels.v2'; -const CHAT_INPUT_PICKER_COLLAPSE_WIDTH = 280; const PERMISSION_LEVEL_OPTION_ID = 'permissionLevel'; +function getToolbarPickerResponsiveItems(toolbar: MenuWorkbenchToolBar, compactStates: ReadonlyMap>): IChatInputPickerResponsiveLayoutItem[] { + const items: IChatInputPickerResponsiveLayoutItem[] = []; + const visibleActionIds = new Set(); + + for (let index = 0; index < toolbar.getItemsLength(); index++) { + const action = toolbar.getItemAction(index); + const state = action && compactStates.get(action.id); + const viewItem = toolbar.getItemViewItem(index); + const viewItemState = isChatInputPickerResponsiveState(viewItem) ? viewItem : undefined; + if (!action || (!state && !viewItemState)) { + continue; + } + visibleActionIds.add(action.id); + const element = toolbar.getItemElement(index); + items.push({ + element, + isCompact: () => viewItemState?.isCompact() ?? state!.get(), + setCompact: compact => { + state?.set(compact, undefined); + viewItemState?.setCompact(compact); + element?.classList.toggle('compact-picker', compact); + }, + }); + } + + for (const [actionId, state] of compactStates) { + if (!visibleActionIds.has(actionId)) { + items.push({ + element: undefined, + isCompact: () => state.get(), + setCompact: compact => state.set(compact, undefined), + }); + } + } + + return items; +} + +type ShowableActionViewItem = IActionViewItem & { show(anchor?: HTMLElement): void }; + +function isShowableActionViewItem(item: IActionViewItem | undefined): item is ShowableActionViewItem { + return !!item && 'show' in item && typeof item.show === 'function'; +} + +function createOverflowAction(action: IAction, run: () => void): IAction { + return { + id: action.id, + label: action.label, + tooltip: action.tooltip, + class: action.class, + enabled: action.enabled, + checked: action.checked, + run, + }; +} + export interface IChatInputStyles { overlayBackground: string; listForeground: string; @@ -236,6 +293,11 @@ export interface IChatInputPartOptions { * chat input part while still using menu-driven rendering. */ secondaryToolbarActionViewItemProvider?: (action: IAction, options?: IActionViewItemOptions) => IActionViewItem | undefined; + /** + * Opens a host-owned secondary picker when its toolbar action moves into overflow. + * Returns true when the action was handled. + */ + secondaryToolbarOverflowActionHandler?: (actionId: string, anchor: HTMLElement) => boolean; /** * When true, the mode picker hides custom agents and only offers the * built-in modes (Agent / Ask / Edit / Plan, gated by their normal @@ -341,7 +403,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private static _counter = 0; private _workingSetCollapsed = observableValue('chatInputPart.workingSetCollapsed', true); - private _stableInputPartWidth = observableValue('chatInputPart.stableInputPartWidth', 0); private readonly _chatInputTodoListWidget = this._register(new MutableDisposable()); private readonly _chatArtifactsWidget = this._register(new MutableDisposable()); private readonly _chatQuestionCarouselWidgets = this._register(new DisposableMap()); @@ -609,6 +670,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private executeToolbar!: MenuWorkbenchToolBar; private inputActionsToolbar!: MenuWorkbenchToolBar; + private _inputPickerResponsiveLayout: ChatInputPickerResponsiveLayout | undefined; + private _secondaryPickerResponsiveLayout: ChatInputPickerResponsiveLayout | undefined; @@ -648,6 +711,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private modeWidget: ModePickerActionItem | undefined; private permissionWidget: PermissionPickerActionItem | undefined; private readonly permissionWidgetDisposeListener = this._register(new MutableDisposable()); + private readonly overflowPickerWidget = this._register(new MutableDisposable()); private sessionTargetWidget: SessionTypePickerActionItem | undefined; private delegationWidget: DelegationSessionPickerActionItem | undefined; private readonly chatSessionPickerWidgets = this._register(new DisposableMap()); @@ -866,6 +930,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge @IChatService private readonly chatService: IChatService, @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, @IChatPetService private readonly chatPetService: IChatPetService, + @IActionViewItemService private readonly actionViewItemService: IActionViewItemService, ) { super(); this._modelSelectionDiagnostics = new ChatModelSelectionDiagnostics(this.logService, this.storageService, () => ({ @@ -3076,6 +3141,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge ]), ]), dom.h('.chat-secondary-toolbar@secondaryToolbar', [ + dom.h('.chat-responsive-picker-container@responsivePickerContainer'), dom.h('.chat-context-usage-container@contextUsageWidgetContainer'), dom.h('.chat-input-status-container@statusToolbarContainer'), ]), @@ -3112,6 +3178,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge ]), ]), dom.h('.chat-secondary-toolbar@secondaryToolbar', [ + dom.h('.chat-responsive-picker-container@responsivePickerContainer'), dom.h('.chat-context-usage-container@contextUsageWidgetContainer'), dom.h('.chat-input-status-container@statusToolbarContainer'), ]), @@ -3138,6 +3205,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.attachedContextContainer = elements.attachedContextContainer; const toolbarsContainer = elements.inputToolbars; this.secondaryToolbarContainer = elements.secondaryToolbar; + const responsivePickerContainer = elements.responsivePickerContainer; if (this.options.renderStyle === 'compact') { this.secondaryToolbarContainer.style.display = 'none'; } @@ -3353,27 +3421,92 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return !hasDraftTarget && (!target || (!!resource && isEqual(target, resource))); }); - const pickerOptions: IChatInputPickerOptions = { + const inputPickerCompactStates = new Map>(); + const secondaryPickerCompactStates = new Map>(); + const inputOverflowPickerHandlers = new Map void>(); + const secondaryOverflowPickerHandlers = new Map void>(); + const getCompactState = (states: Map>, actionId: string): ISettableObservable => { + let state = states.get(actionId); + if (!state) { + state = observableValue(this, false); + states.set(actionId, state); + } + return state; + }; + const getInputPickerOptions = (actionId: string): IChatInputPickerOptions => ({ getOverflowAnchor: () => this.inputActionsToolbar.getElement(), actionContext: { widget }, - compact: derived(reader => this._stableInputPartWidth.read(reader) < CHAT_INPUT_PICKER_COLLAPSE_WIDTH), - }; - const primarySessionPickerOptions: IChatInputPickerOptions = { - ...pickerOptions, - compact: constObservable(true), - }; - const secondaryPickerOptions: IChatInputPickerOptions = { - ...pickerOptions, + compact: getCompactState(inputPickerCompactStates, actionId), + }); + const getSecondaryPickerOptions = (actionId: string): IChatInputPickerOptions => ({ getOverflowAnchor: () => this.secondaryToolbar.getElement(), - compact: constObservable(true), + actionContext: { widget }, + compact: getCompactState(secondaryPickerCompactStates, actionId), + }); + const showOverflowPicker = (factory: () => ShowableActionViewItem | undefined, anchor: HTMLElement): void => { + const item = factory(); + if (!item) { + return; + } + this.overflowPickerWidget.value = item; + item.render(dom.$('.chat-overflow-picker-item')); + item.show(anchor); + }; + const showRegisteredOverflowPicker = (factory: IActionViewItemFactory, action: IAction, anchor: HTMLElement): boolean => { + const item = factory(action, { hoverDelegate }, this.instantiationService, dom.getWindow(anchor).vscodeWindowId); + if (!isShowableActionViewItem(item)) { + item?.dispose(); + return false; + } + this.overflowPickerWidget.value = item; + item.render(dom.$('.chat-overflow-picker-item')); + item.show(anchor); + return true; + }; + const getOverflowAction = ( + action: IAction, + menuId: MenuId, + handlers: ReadonlyMap void>, + getAnchor: () => HTMLElement | undefined, + fallbackAnchor: HTMLElement, + hostHandler?: (actionId: string, anchor: HTMLElement) => boolean, + ): IAction => { + const handler = handlers.get(action.id); + const registeredFactory = this.actionViewItemService.lookUp(menuId, action.id); + if (!handler && !hostHandler && !registeredFactory) { + return action; + } + return createOverflowAction(action, () => { + const overflowAnchor = getAnchor(); + const anchor = overflowAnchor ?? fallbackAnchor; + dom.getWindow(anchor).setTimeout(() => { + overflowAnchor?.focus(); + if (handler) { + handler(anchor); + } else if (hostHandler?.(action.id, anchor)) { + return; + } else if (registeredFactory && showRegisteredOverflowPicker(registeredFactory, action, anchor)) { + return; + } else { + void action.run({ widget } satisfies IChatExecuteActionContext); + } + }, 0); + }); }; - this._register(dom.addStandardDisposableListener(toolbarsContainer, dom.EventType.CLICK, e => this.inputEditor.focus())); - this._register(dom.addStandardDisposableListener(this.attachmentsContainer, dom.EventType.CLICK, e => this.inputEditor.focus())); const shorterChatInputActionIds = new Set([ OpenModePickerAction.ID, ConfigureToolsAction.ID, ]); + const getInputActionMinWidth = (action: IAction): number | undefined => { + if (shorterChatInputActionIds.has(action.id)) { + return 22; + } + return inputPickerCompactStates.get(action.id)?.get() ? 22 : undefined; + }; + + this._register(dom.addStandardDisposableListener(toolbarsContainer, dom.EventType.CLICK, e => this.inputEditor.focus())); + this._register(dom.addStandardDisposableListener(this.attachmentsContainer, dom.EventType.CLICK, e => this.inputEditor.focus())); this.inputActionsToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, this.options.renderInputToolbarBelowInput ? this.attachmentsContainer : toolbarsContainer, MenuId.ChatInput, { telemetrySource: this.options.menus.telemetrySource, menuOptions: { shouldForwardArgs: true }, @@ -3384,7 +3517,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge kind: 'last', minItems: 1, actionMinWidth: 48, - getActionMinWidth: action => shorterChatInputActionIds.has(action.id) ? 22 : undefined, + getActionMinWidth: getInputActionMinWidth, + allowOverflow: () => this._inputPickerResponsiveLayout?.areAllItemsCompact() === true, + getOverflowAction: (action, getAnchor) => getOverflowAction(action, MenuId.ChatInput, inputOverflowPickerHandlers, getAnchor, toolbarsContainer), }, actionViewItemProvider: (action, options) => { // Phone-layout branch: when an agents-window phone presenter @@ -3414,10 +3549,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } const itemDelegate: IModelPickerDelegate = this._createModelPickerDelegate(); - return this.modelWidget = this.instantiationService.createInstance(ModelPickerActionItem, action, itemDelegate, pickerOptions); + const createPicker = () => this.instantiationService.createInstance(ModelPickerActionItem, action, itemDelegate, getInputPickerOptions(action.id)); + inputOverflowPickerHandlers.set(action.id, anchor => showOverflowPicker(createPicker, anchor)); + return this.modelWidget = createPicker(); } else if (action.id === OpenModePickerAction.ID && action instanceof MenuItemAction) { const delegate: IModePickerDelegate = this._createModePickerDelegate(); - return this.modeWidget = this.instantiationService.createInstance(ModePickerActionItem, action, delegate, pickerOptions); + const createPicker = () => this.instantiationService.createInstance(ModePickerActionItem, action, delegate, getInputPickerOptions(action.id)); + inputOverflowPickerHandlers.set(action.id, anchor => showOverflowPicker(createPicker, anchor)); + return this.modeWidget = createPicker(); } else if ((action.id === OpenSessionTargetPickerAction.ID || action.id === OpenDelegationPickerAction.ID) && action instanceof MenuItemAction) { // Use provided delegate if available, otherwise create default delegate const delegate: ISessionTypePickerDelegate = this.options.sessionTypePickerDelegate ?? { @@ -3434,14 +3573,23 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }; const isWelcomeViewMode = !!this.options.sessionTypePickerDelegate?.setActiveSessionProvider; const Picker = (action.id === OpenSessionTargetPickerAction.ID || isWelcomeViewMode) ? SessionTypePickerActionItem : DelegationSessionPickerActionItem; - return this.sessionTargetWidget = this.instantiationService.createInstance(Picker, action, location === ChatWidgetLocation.Editor ? 'editor' : 'sidebar', delegate, pickerOptions); - } else if (action.id === ChatSessionPrimaryPickerAction.ID && action instanceof MenuItemAction) { - // Cloud sessions render their option-group pickers (e.g. branch) on the primary toolbar - const widgets = this.createChatSessionPickerWidgets(action, primarySessionPickerOptions); - if (widgets.length === 0) { - return new HiddenActionViewItem(action); + const createPicker = () => this.instantiationService.createInstance(Picker, action, location === ChatWidgetLocation.Editor ? 'editor' : 'sidebar', delegate, getInputPickerOptions(action.id)); + inputOverflowPickerHandlers.set(action.id, anchor => showOverflowPicker(createPicker, anchor)); + const picker = createPicker(); + if (picker instanceof DelegationSessionPickerActionItem) { + this.delegationWidget = picker; + } else { + this.sessionTargetWidget = picker; } - return this.instantiationService.createInstance(ChatSessionPickersContainerActionItem, action, widgets); + return picker; + } else if (action.id === ChatSessionPrimaryPickerAction.ID && action instanceof MenuItemAction) { + const createPicker = () => { + // Cloud sessions render their option-group pickers (e.g. branch) on the primary toolbar + const widgets = this.createChatSessionPickerWidgets(action, getInputPickerOptions(action.id)); + return widgets.length === 0 ? undefined : this.instantiationService.createInstance(ChatSessionPickersContainerActionItem, action, widgets); + }; + inputOverflowPickerHandlers.set(action.id, anchor => showOverflowPicker(createPicker, anchor)); + return createPicker() ?? new HiddenActionViewItem(action); } return undefined; } @@ -3460,17 +3608,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._toolbarRelayoutScheduler.schedule(); } })); - // When compact changes, picker items change their rendered size - // but the toolbar's ResizeObserver won't fire (the toolbar element size - // didn't change, only its children did). Force a relayout so the - // responsive overflow logic re-evaluates with the correct item widths. - // The relayout is deferred by a microtask so the picker action view - // items' own autoruns have a chance to re-render their labels first. - this._register(autorun(reader => { - pickerOptions.compact.read(reader); - queueMicrotask(() => this.inputActionsToolbar.relayout()); - })); - // When the phone-input presenter flips between enabled/disabled (e.g. // device rotation crossing the phone breakpoint), the action view item // provider above will return different items. Force the toolbar to @@ -3567,13 +3704,20 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } // Secondary toolbar (permissions) — below the input box. - // Per-action minimum widths (in pixels) for pickers that collapse to an - // icon-only label via a CSS container query in `AgentHostChatInputPicker`. - // Most pickers reserve ~22px for the icon; the tunnel-sharing toggle has - // no chevron, so it can collapse further to 16px. - const agentHostShortPickerMinWidths = new Map([ + // Compact-capable pickers use their 22px control width as the responsive + // floor so icon-only items do not retain empty space from the labeled form. + // The tunnel-sharing toggle has no chevron and can collapse further. + const secondaryPickerMinWidths = new Map([ + [OpenSessionTargetPickerAction.ID, 22], + [OpenDelegationPickerAction.ID, 22], + [OpenWorkspacePickerAction.ID, 22], + [OpenPermissionPickerAction.ID, 22], + [ChatSessionPrimaryPickerAction.ID, 22], [OpenAgentHostModePickerAction.ID, 22], ['sessions.agentHost.runningSessionModePicker', 22], + ['sessions.agentHost.runningSessionConfigPicker', 22], + ['sessions.agentHost.runningSessionPermissionModePicker', 22], + ['sessions.agentHost.runningSessionCodexApprovalsPicker', 22], [OpenAgentHostAutoApprovePickerAction.ID, 22], [OpenAgentHostPermissionModePickerAction.ID, 22], [OpenAgentHostCodexApprovalsPickerAction.ID, 22], @@ -3583,16 +3727,22 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Direct-rendered chip lane for agent-host config properties that // are advertised by the agent's schema but not handled by a // dedicated `MenuId.ChatInputSecondary` action. Sits as a sibling - // of the secondary toolbar so the toolbar can take the available - // space (`flex: 1 1 0`) while the chips pin to the right next to - // the context-usage widget. + // of the content-sized secondary toolbar. const genericChipsContainer = dom.$('.chat-secondary-generic-chips'); const genericChipsLane = this._register(this.instantiationService.createInstance( AgentHostGenericConfigChips, widget, )); genericChipsLane.render(genericChipsContainer); - this.secondaryToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, this.secondaryToolbarContainer, MenuId.ChatInputSecondary, { + const getSecondaryToolbarAvailableWidth = (): number => { + const laneWidth = responsivePickerContainer.getBoundingClientRect().width; + if (genericChipsContainer.parentElement !== responsivePickerContainer || genericChipsContainer.getClientRects().length === 0) { + return laneWidth; + } + const gap = Number.parseFloat(dom.getWindow(responsivePickerContainer).getComputedStyle(responsivePickerContainer).columnGap) || 0; + return Math.max(0, laneWidth - genericChipsContainer.getBoundingClientRect().width - gap); + }; + this.secondaryToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, responsivePickerContainer, MenuId.ChatInputSecondary, { telemetrySource: this.options.menus.telemetrySource, menuOptions: { shouldForwardArgs: true }, hiddenItemStrategy: HiddenItemStrategy.NoHide, @@ -3602,16 +3752,17 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge kind: 'all', minItems: 1, actionMinWidth: 48, - // Agent-host pickers collapse to an icon-only label via a CSS - // container query in `AgentHostChatInputPicker` when narrow. - // Report a smaller min-width for them so the responsive layout - // keeps them visible instead of overflowing into the menu. - getActionMinWidth: action => agentHostShortPickerMinWidths.get(action.id), + getActionMinWidth: action => secondaryPickerMinWidths.get(action.id) ?? (secondaryPickerCompactStates.get(action.id)?.get() ? 22 : undefined), + observedElement: responsivePickerContainer, + getAvailableWidth: getSecondaryToolbarAvailableWidth, + allowOverflow: () => this._secondaryPickerResponsiveLayout?.areAllItemsCompact() === true, + getOverflowAction: (action, getAnchor) => getOverflowAction(action, MenuId.ChatInputSecondary, secondaryOverflowPickerHandlers, getAnchor, responsivePickerContainer, this.options.secondaryToolbarOverflowActionHandler), }, actionViewItemProvider: (action, options) => { const agentHostPickerProperty = getAgentHostPickerProperty(action.id); const customSecondaryItem = this.options.secondaryToolbarActionViewItemProvider?.(action, options); if (customSecondaryItem) { + getCompactState(secondaryPickerCompactStates, action.id); return customSecondaryItem; } if ((action.id === OpenSessionTargetPickerAction.ID || action.id === OpenDelegationPickerAction.ID) && action instanceof MenuItemAction) { @@ -3629,10 +3780,21 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }; const isWelcomeViewMode = !!this.options.sessionTypePickerDelegate?.setActiveSessionProvider; const Picker = (action.id === OpenSessionTargetPickerAction.ID || isWelcomeViewMode) ? SessionTypePickerActionItem : DelegationSessionPickerActionItem; - return this.sessionTargetWidget = this.instantiationService.createInstance(Picker, action, location === ChatWidgetLocation.Editor ? 'editor' : 'sidebar', delegate, secondaryPickerOptions); + const createPicker = () => this.instantiationService.createInstance(Picker, action, location === ChatWidgetLocation.Editor ? 'editor' : 'sidebar', delegate, getSecondaryPickerOptions(action.id)); + secondaryOverflowPickerHandlers.set(action.id, anchor => showOverflowPicker(createPicker, anchor)); + const picker = createPicker(); + if (picker instanceof DelegationSessionPickerActionItem) { + this.delegationWidget = picker; + } else { + this.sessionTargetWidget = picker; + } + return picker; } else if (action.id === OpenWorkspacePickerAction.ID && action instanceof MenuItemAction) { - if (this.workspaceContextService.getWorkbenchState() === WorkbenchState.EMPTY && this.options.workspacePickerDelegate) { - return this.instantiationService.createInstance(WorkspacePickerActionItem, action, this.options.workspacePickerDelegate, secondaryPickerOptions); + const workspacePickerDelegate = this.options.workspacePickerDelegate; + if (this.workspaceContextService.getWorkbenchState() === WorkbenchState.EMPTY && workspacePickerDelegate) { + const createPicker = () => this.instantiationService.createInstance(WorkspacePickerActionItem, action, workspacePickerDelegate, getSecondaryPickerOptions(action.id)); + secondaryOverflowPickerHandlers.set(action.id, anchor => showOverflowPicker(createPicker, anchor)); + return createPicker(); } else { return new HiddenActionViewItem(action); } @@ -3672,7 +3834,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }, isSandboxToggleApplicable: () => this.getEffectiveSessionType(this.getCurrentSessionResource()) === SessionType.Local, }; - const widget = this.instantiationService.createInstance(PermissionPickerActionItem, action, delegate, secondaryPickerOptions); + const createPicker = () => this.instantiationService.createInstance(PermissionPickerActionItem, action, delegate, getSecondaryPickerOptions(action.id)); + secondaryOverflowPickerHandlers.set(action.id, anchor => showOverflowPicker(createPicker, anchor)); + const widget = createPicker(); this.permissionWidget = widget; this.permissionWidgetDisposeListener.value = widget.onDidDispose(() => { if (this.permissionWidget === widget) { @@ -3685,28 +3849,35 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (this.options.isSessionsWindow) { return new HiddenActionViewItem(action); } - const picker = this.instantiationService.createInstance(AgentHostChatInputPicker, widget, agentHostPickerProperty); - return new AgentHostChatInputPickerActionViewItem(action, picker); + getCompactState(secondaryPickerCompactStates, action.id); + const createPicker = () => this.instantiationService.createInstance(AgentHostChatInputPicker, widget, agentHostPickerProperty); + secondaryOverflowPickerHandlers.set(action.id, anchor => { + const picker = createPicker(); + this.overflowPickerWidget.value = picker; + picker.show(anchor); + }); + return new AgentHostChatInputPickerActionViewItem(action, createPicker()); } else if (action.id === OpenAgentHostFolderPickerAction.ID && action instanceof MenuItemAction) { if (this.options.isSessionsWindow) { return new HiddenActionViewItem(action); } - return this.instantiationService.createInstance(AgentHostFolderPickerActionItem, action, widget, secondaryPickerOptions); + const createPicker = () => this.instantiationService.createInstance(AgentHostFolderPickerActionItem, action, widget, getSecondaryPickerOptions(action.id)); + secondaryOverflowPickerHandlers.set(action.id, anchor => showOverflowPicker(createPicker, anchor)); + return createPicker(); } else if (action.id === ChatSessionPrimaryPickerAction.ID && action instanceof MenuItemAction) { - // Create all pickers and return a container action view item - const widgets = this.createChatSessionPickerWidgets(action, secondaryPickerOptions); - if (widgets.length === 0) { - return new HiddenActionViewItem(action); - } - // Create a container to hold all picker widgets - return this.instantiationService.createInstance(ChatSessionPickersContainerActionItem, action, widgets); + const createPicker = () => { + const widgets = this.createChatSessionPickerWidgets(action, getSecondaryPickerOptions(action.id)); + return widgets.length === 0 ? undefined : this.instantiationService.createInstance(ChatSessionPickersContainerActionItem, action, widgets); + }; + secondaryOverflowPickerHandlers.set(action.id, anchor => showOverflowPicker(createPicker, anchor)); + return createPicker() ?? new HiddenActionViewItem(action); } return undefined; } })); this.secondaryToolbar.getElement().classList.add('chat-secondary-input-toolbar'); this.secondaryToolbar.context = { widget } satisfies IChatExecuteActionContext; - dom.append(this.secondaryToolbarContainer, genericChipsContainer); + dom.append(responsivePickerContainer, genericChipsContainer); this._register(this.secondaryToolbar.onDidChangeMenuItems(() => { // Update container reference for the pickers when the secondary toolbar hosts one. // Only assign when found so we don't overwrite a valid primary container reference @@ -3729,6 +3900,30 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.statusToolbar.getElement().classList.add('chat-input-status-toolbar'); this.statusToolbar.context = { widget } satisfies IChatExecuteActionContext; + const inputToolbarElement = this.inputActionsToolbar.getElement(); + this._inputPickerResponsiveLayout = this._register(new ChatInputPickerResponsiveLayout('ChatInputPart.primaryPicker', inputToolbarElement, { + getItems: () => getToolbarPickerResponsiveItems(this.inputActionsToolbar, inputPickerCompactStates), + hasOverflow: () => this.inputActionsToolbar.hasOverflow(), + relayout: () => this.inputActionsToolbar.relayout(), + })); + + this._secondaryPickerResponsiveLayout = this._register(new ChatInputPickerResponsiveLayout('ChatInputPart.secondaryPicker', responsivePickerContainer, { + getItems: () => [ + ...getToolbarPickerResponsiveItems(this.secondaryToolbar, secondaryPickerCompactStates), + ...genericChipsLane.getCompactableElements() + .map(element => ({ + element, + isCompact: () => element.classList.contains('compact-picker'), + setCompact: (compact: boolean) => element.classList.toggle('compact-picker', compact), + })), + ], + hasOverflow: () => this.secondaryToolbar.hasOverflow(), + relayout: () => this.secondaryToolbar.relayout(), + })); + + this._inputPickerResponsiveLayout.layout(); + this._secondaryPickerResponsiveLayout.layout(); + let inputModel = this.modelService.getModel(this.inputUri); let createdInputModel: ITextModel | undefined; if (!inputModel) { @@ -4783,10 +4978,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge */ layout(width: number) { this.cachedWidth = width; - this._stableInputPartWidth.set(width, undefined); this._updateWorkingProgressAnimationDuration(width); - return this._layout(width); + const result = this._layout(width); + this._inputPickerResponsiveLayout?.layout(); + this._secondaryPickerResponsiveLayout?.layout(); + return result; } private layoutForToolbarChange(): void { @@ -5038,6 +5235,10 @@ class ChatSessionPickersContainerActionItem extends ActionViewItem { } } + show(): void { + this.widgets[0]?.show(); + } + override dispose(): void { for (const widget of this.widgets) { widget.dispose(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts index ddc4987f232..d1c3aea1d57 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts @@ -42,6 +42,7 @@ export function withChatInputPickerMotion(listOptions: IActionListOptions | unde * Provides common anchor resolution logic for dropdown positioning. */ export abstract class ChatInputPickerActionViewItem extends ActionWidgetDropdownActionViewItem { + private _externalAnchor: HTMLElement | undefined; constructor( action: IAction, @@ -80,12 +81,20 @@ export abstract class ChatInputPickerActionViewItem extends ActionWidgetDropdown * Falls back to the overflow anchor if this element is not in the DOM. */ protected getAnchorElement(): HTMLElement { + if (this._externalAnchor?.isConnected) { + return this._externalAnchor; + } if (this.element && getActiveWindow().document.contains(this.element)) { return this.element; } return this.pickerOptions.getOverflowAnchor?.() ?? this.element!; } + override show(anchor?: HTMLElement): void { + this._externalAnchor = anchor; + super.show(); + } + override render(container: HTMLElement): void { super.render(container); container.classList.add('chat-input-picker-item'); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.ts new file mode 100644 index 00000000000..67f8044dd2e --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.ts @@ -0,0 +1,229 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../base/browser/dom.js'; +import { Disposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; + +const WIDTH_TOLERANCE = 1; + +export interface IChatInputPickerResponsiveLayoutDelegate { + getItems(): readonly IChatInputPickerResponsiveLayoutItem[]; + hasOverflow?(): boolean; + relayout?(): void; +} + +export interface IChatInputPickerResponsiveState { + isCompact(): boolean; + setCompact(compact: boolean): void; +} + +export interface IChatInputPickerResponsiveLayoutItem extends IChatInputPickerResponsiveState { + readonly element: HTMLElement | undefined; +} + +export function isChatInputPickerResponsiveState(candidate: object | undefined): candidate is IChatInputPickerResponsiveState { + return !!candidate + && 'isCompact' in candidate + && typeof candidate.isCompact === 'function' + && 'setCompact' in candidate + && typeof candidate.setCompact === 'function'; +} + +/** + * Compacts a picker lane only when its expanded contents no longer fit the width assigned by its surrounding layout. + */ +export class ChatInputPickerResponsiveLayout extends Disposable { + + private readonly _mutationObserver: MutationObserver; + private _isLayouting = false; + + constructor( + name: string, + private readonly _element: HTMLElement, + private readonly _delegate: IChatInputPickerResponsiveLayoutDelegate, + ) { + super(); + + const targetWindow = dom.getWindow(_element); + const resizeObserver = this._register(new dom.DisposableResizeObserver(name, () => this.layout(), targetWindow)); + this._register(resizeObserver.observe(_element)); + + this._mutationObserver = new targetWindow.MutationObserver(() => this.layout()); + this._observeMutations(); + this._register(toDisposable(() => this._mutationObserver.disconnect())); + } + + layout(): void { + if (this._isLayouting || !this._element.isConnected) { + return; + } + + const availableWidth = this._element.getBoundingClientRect().width; + if (availableWidth <= 0) { + return; + } + + this._isLayouting = true; + this._mutationObserver.disconnect(); + try { + // Restore as many hidden actions as possible in their shortest form + // before measuring. Otherwise an overflow menu can hide the very items + // whose expanded width should keep the lane compact. + this._setAllCompact(true); + this._delegate.relayout?.(); + this._setAllCompact(true); + this._delegate.relayout?.(); + if (this._delegate.hasOverflow?.()) { + return; + } + + const items = this._getOrderedVisibleItems(); + for (const item of items) { + item.setCompact(false); + } + + for (const item of items) { + if (this._fitsAvailableWidth(availableWidth)) { + break; + } + item.setCompact(true); + } + this._delegate.relayout?.(); + } finally { + this._observeMutations(); + this._isLayouting = false; + } + } + + areAllItemsCompact(): boolean { + return this._delegate.getItems().every(item => item.isCompact()); + } + + private _setAllCompact(compact: boolean): void { + for (const item of this._delegate.getItems()) { + item.setCompact(compact); + } + } + + private _getOrderedVisibleItems(): IChatInputPickerResponsiveLayoutItem[] { + return this._delegate.getItems() + .filter(item => item.element?.isConnected && item.element.getClientRects().length > 0) + .sort((a, b) => b.element!.getBoundingClientRect().left - a.element!.getBoundingClientRect().left); + } + + private _fitsAvailableWidth(availableWidth: number): boolean { + const items = this._getOrderedVisibleItems(); + const preferredLayout = this._measurePreferredLayout(items); + if (preferredLayout.width > availableWidth + WIDTH_TOLERANCE) { + return false; + } + + const laneBounds = this._element.getBoundingClientRect(); + const itemBounds = items + .map(item => ({ item, bounds: item.element!.getBoundingClientRect() })) + .sort((a, b) => a.bounds.left - b.bounds.left); + for (let index = 0; index < itemBounds.length; index++) { + const { item, bounds } = itemBounds[index]; + if (bounds.left < laneBounds.left - WIDTH_TOLERANCE || bounds.right > laneBounds.right + WIDTH_TOLERANCE) { + return false; + } + if (index > 0 && bounds.left < itemBounds[index - 1].bounds.right - WIDTH_TOLERANCE) { + return false; + } + const preferredWidth = preferredLayout.itemWidths.get(item); + if (preferredWidth !== undefined && bounds.width < preferredWidth - WIDTH_TOLERANCE) { + return false; + } + } + return true; + } + + private _measurePreferredLayout(items: readonly IChatInputPickerResponsiveLayoutItem[]): { width: number; itemWidths: ReadonlyMap } { + const parent = this._element.parentElement; + if (!parent) { + return { width: 0, itemWidths: new Map() }; + } + + const measurementHost = dom.$('.chat-input-picker-measurement-host'); + measurementHost.style.position = 'fixed'; + measurementHost.style.inset = '0 auto auto 0'; + measurementHost.style.width = '0'; + measurementHost.style.height = '0'; + measurementHost.style.overflow = 'hidden'; + measurementHost.style.contain = 'strict'; + measurementHost.style.visibility = 'hidden'; + measurementHost.style.pointerEvents = 'none'; + + const measurement = this._element.cloneNode(true) as HTMLElement; + measurement.setAttribute('aria-hidden', 'true'); + measurement.setAttribute('inert', ''); + measurement.style.position = 'absolute'; + measurement.style.left = '0'; + measurement.style.top = '0'; + measurement.style.width = 'max-content'; + measurement.style.minWidth = 'max-content'; + measurement.style.maxWidth = 'none'; + measurement.style.flex = 'none'; + measurementHost.appendChild(measurement); + parent.appendChild(measurementHost); + try { + const itemWidths = new Map(); + for (const item of items) { + const path = item.element ? this._getElementPath(item.element) : undefined; + const measuredItem = path ? this._getElementAtPath(measurement, path) : undefined; + if (measuredItem) { + measuredItem.style.flex = 'none'; + measuredItem.style.width = 'max-content'; + measuredItem.style.minWidth = 'max-content'; + measuredItem.style.maxWidth = 'none'; + itemWidths.set(item, measuredItem.getBoundingClientRect().width); + } + } + return { width: measurement.getBoundingClientRect().width, itemWidths }; + } finally { + measurementHost.remove(); + } + } + + private _getElementPath(element: HTMLElement): readonly number[] | undefined { + const path: number[] = []; + let current: HTMLElement | null = element; + while (current && current !== this._element) { + const parent: HTMLElement | null = current.parentElement; + if (!parent) { + return undefined; + } + const index = Array.from(parent.children).indexOf(current); + if (index < 0) { + return undefined; + } + path.unshift(index); + current = parent; + } + return current === this._element ? path : undefined; + } + + private _getElementAtPath(root: HTMLElement, path: readonly number[]): HTMLElement | undefined { + let current: Element = root; + for (const index of path) { + const child = current.children.item(index); + if (!child) { + return undefined; + } + current = child; + } + return dom.isHTMLElement(current) ? current : undefined; + } + + private _observeMutations(): void { + this._mutationObserver.observe(this._element, { + attributes: true, + attributeFilter: ['class', 'hidden', 'style'], + characterData: true, + childList: true, + subtree: true, + }); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts index 7b800cc5ba5..5ec50cb2725 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -294,8 +294,6 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { } protected override renderLabel(element: HTMLElement): IDisposable | null { - this.setAriaLabelAttributes(element); - const currentMode = this.delegate.currentMode.get(); const state = currentMode.label.get(); let icon = currentMode.icon.get(); @@ -307,6 +305,7 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { const labelElements = []; const collapsed = this.pickerOptions.compact.get(); + element.classList.toggle('icon-only', collapsed && !!icon); if (icon) { labelElements.push(...renderLabelWithIcons(`$(${getCompactCodicon(icon).id})`)); } @@ -315,6 +314,8 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { } dom.reset(element, ...labelElements); + this.setAriaLabelAttributes(element); + element.ariaLabel = state; return null; } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css index 0a7f2fbcb81..601a7065c5f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css @@ -51,14 +51,17 @@ } .chat-input-picker-item .action-label.model-picker-split .model-picker-name { - min-width: 0; - flex-shrink: 1; - overflow: hidden; + flex-shrink: 0; + overflow: visible; +} + +.interactive-session .chat-input-toolbar .chat-input-picker-item.compact-picker .action-label.model-picker-split.compact { + justify-content: flex-start; } .chat-input-picker-item .action-label.model-picker-split .model-picker-name .chat-input-picker-label { - overflow: hidden; - text-overflow: ellipsis; + overflow: visible; + text-overflow: clip; } .chat-input-picker-item .action-label.model-picker-split .model-picker-config { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts index 973051a341d..75a3c1bad1a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts @@ -128,8 +128,8 @@ export class ModelPickerActionItem extends BaseActionViewItem { this._showPicker(); } - public show(): void { - this._showPicker(); + public show(anchor?: HTMLElement): void { + this._pickerWidget.show(anchor ?? this._getAnchorElement()); } public setEnabled(enabled: boolean): void { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts index 2492ec91ee8..a2d0778e6a6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts @@ -387,7 +387,11 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { const labelElements = []; labelElements.push(...renderLabelWithIcons(`$(${getCompactCodicon(icon).id})`)); - labelElements.push(dom.$('span.chat-input-picker-label', undefined, label)); + const compact = this.pickerOptions.compact.get(); + element.classList.toggle('icon-only', compact); + if (!compact) { + labelElements.push(dom.$('span.chat-input-picker-label', undefined, label)); + } dom.reset(element, ...labelElements); element.classList.toggle('warning', !ext && (level === ChatPermissionLevel.Autopilot || level === ChatPermissionLevel.Assisted)); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts index 2062b99c727..43d8839dff9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -365,7 +365,6 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { } protected override renderLabel(element: HTMLElement): IDisposable | null { - this.setAriaLabelAttributes(element); const currentType = this._getSelectedSessionType() ?? this._getDefaultSessionType(); // TODO: Remove hardcoded providers from core @@ -377,9 +376,15 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { const labelElements = []; labelElements.push(...renderLabelWithIcons(`$(${getCompactCodicon(icon).id})`)); - labelElements.push(dom.$('span.chat-input-picker-label', undefined, label)); + const compact = this.pickerOptions.compact.get(); + element.classList.toggle('icon-only', compact); + if (!compact) { + labelElements.push(dom.$('span.chat-input-picker-label', undefined, label)); + } dom.reset(element, ...labelElements); + this.setAriaLabelAttributes(element); + element.ariaLabel = label; return null; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/workspacePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/workspacePickerActionItem.ts index 812b0bd282c..710cb57d85d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/workspacePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/workspacePickerActionItem.ts @@ -103,22 +103,23 @@ export class WorkspacePickerActionItem extends ChatInputPickerActionViewItem { } protected override renderLabel(element: HTMLElement): IDisposable | null { - this.setAriaLabelAttributes(element); const currentWorkspace = this.delegate.getSelectedWorkspace(); const labelElements: (string | HTMLElement)[] = []; + const label = currentWorkspace + ? currentWorkspace.label || basename(currentWorkspace.uri) + : localize('selectWorkspace', "Workspace"); + const compact = this.pickerOptions.compact.get(); + element.classList.toggle('icon-only', compact); - if (currentWorkspace) { - // Show the workspace label or folder name - const label = currentWorkspace.label || basename(currentWorkspace.uri); - labelElements.push(...renderLabelWithIcons(`$(folder-compact)`)); + labelElements.push(...renderLabelWithIcons(`$(folder-compact)`)); + if (!compact) { labelElements.push(dom.$('span.chat-input-picker-label', undefined, label)); - } else { - labelElements.push(...renderLabelWithIcons(`$(folder-compact)`)); - labelElements.push(dom.$('span.chat-input-picker-label', undefined, localize('selectWorkspace', "Workspace"))); } dom.reset(element, ...labelElements); + this.setAriaLabelAttributes(element); + element.ariaLabel = label; return null; } 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 8bb557470f9..60f33e5621f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -1843,6 +1843,14 @@ have to be updated for changes to the rules above, or to support more deeply nes display: none; } +.interactive-session .chat-secondary-toolbar .chat-responsive-picker-container { + display: flex; + align-items: center; + flex: 1 1 0; + min-width: 0; + gap: 2px; +} + .interactive-session .chat-secondary-toolbar .chat-secondary-generic-chips { display: flex; align-items: center; @@ -1850,10 +1858,14 @@ have to be updated for changes to the rules above, or to support more deeply nes gap: 2px; } +.interactive-session .chat-secondary-toolbar .chat-secondary-generic-chips:empty { + display: none; +} + .interactive-session .chat-secondary-toolbar .chat-secondary-input-toolbar { overflow: hidden; min-width: 0px; - flex: 1 1 0; + flex: 0 1 auto; color: var(--vscode-icon-foreground); .monaco-action-bar .action-item .codicon { @@ -1865,16 +1877,19 @@ have to be updated for changes to the rules above, or to support more deeply nes .chat-input-picker-item { min-width: 0px; - overflow: hidden; + overflow: visible; + flex-shrink: 0; .action-label { min-width: 0px; - overflow: hidden; + overflow: visible; position: relative; .chat-input-picker-label { - overflow: hidden; - text-overflow: ellipsis; + flex-shrink: 0; + overflow: visible; + text-overflow: clip; + white-space: nowrap; } .codicon + .chat-input-picker-label { @@ -1945,16 +1960,19 @@ have to be updated for changes to the rules above, or to support more deeply nes .chat-input-picker-item { min-width: 0px; - overflow: hidden; + overflow: visible; + flex-shrink: 0; .action-label { min-width: 0px; - overflow: hidden; + overflow: visible; position: relative; .chat-input-picker-label { - overflow: hidden; - text-overflow: ellipsis; + flex-shrink: 0; + overflow: visible; + text-overflow: clip; + white-space: nowrap; } .model-picker-badge { @@ -2020,11 +2038,12 @@ have to be updated for changes to the rules above, or to support more deeply nes background-color: var(--vscode-toolbar-hoverBackground); } -/* When chevrons are hidden and only showing an icon (no label), size to 22x22 with centered icon */ -.interactive-session .chat-input-toolbar .chat-input-picker-item .action-label.compact:not(:has(.chat-input-picker-label)), -.interactive-session .chat-input-toolbar .chat-input-picker-item.compact .action-label:not(:has(.chat-input-picker-label)), -.interactive-session .chat-input-toolbar .chat-sessionPicker-item .action-label.compact:not(:has(.chat-input-picker-label)), -.interactive-session .chat-secondary-input-toolbar .chat-sessionPicker-item .action-label.compact:not(:has(.chat-input-picker-label)) { +/* When only the icon remains, keep the expanded control's leading inset so + * the glyph does not move as the label disappears. */ +.interactive-session .chat-input-toolbar .chat-input-picker-item .action-label.icon-only, +.interactive-session .chat-secondary-input-toolbar .chat-input-picker-item .action-label.icon-only, +.interactive-session .chat-input-toolbar .chat-sessionPicker-item .action-label.icon-only, +.interactive-session .chat-secondary-input-toolbar .chat-sessionPicker-item .action-label.icon-only { width: 22px; min-width: 22px; height: 22px; @@ -2039,6 +2058,18 @@ have to be updated for changes to the rules above, or to support more deeply nes } } +.interactive-session .chat-input-toolbar .chat-input-picker-item .action-label.icon-only:not(.model-picker-split), +.interactive-session .chat-input-toolbar .chat-sessionPicker-item .action-label.icon-only { + padding-left: var(--vscode-spacing-size60); + justify-content: flex-start; +} + +.interactive-session .chat-secondary-input-toolbar .chat-input-picker-item .action-label.icon-only, +.interactive-session .chat-secondary-input-toolbar .chat-sessionPicker-item .action-label.icon-only { + padding-left: var(--vscode-spacing-size80); + justify-content: flex-start; +} + /* Icon-only chips in the primary input toolbar (add context, configure tools, MCP servers) all sit on the compact tier, so the row reads as one dense strip diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerResponsiveLayout.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerResponsiveLayout.test.ts new file mode 100644 index 00000000000..1950c44e45a --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerResponsiveLayout.test.ts @@ -0,0 +1,327 @@ +/*--------------------------------------------------------------------------------------------- + * 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 * as dom from '../../../../../../../base/browser/dom.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { ChatInputPickerResponsiveLayout } from '../../../../browser/widget/input/chatInputPickerResponsiveLayout.js'; +import '../../../../browser/widget/input/modelPicker/media/modelPicker.css'; +import '../../../../browser/widget/media/chat.css'; + +suite('ChatInputPickerResponsiveLayout', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + let host: HTMLElement; + + setup(() => { + host = dom.append(document.body, dom.$('.chat-input-picker-responsive-layout-test')); + }); + + teardown(() => { + host.remove(); + }); + + test('uses the rendered picker width instead of a viewport threshold', () => { + const lane = dom.append(host, dom.$('.picker-lane')); + lane.style.display = 'flex'; + lane.style.width = '120px'; + lane.style.overflow = 'hidden'; + + const picker = dom.append(lane, dom.$('.picker')); + picker.style.flex = '0 0 auto'; + picker.style.width = '240px'; + + let compact = false; + let expandedWidth = 240; + const layout = store.add(new ChatInputPickerResponsiveLayout('test.pickerLane', lane, { + getItems: () => [{ + element: picker, + isCompact: () => compact, + setCompact: value => { + compact = value; + picker.style.width = value ? '20px' : `${expandedWidth}px`; + }, + }], + })); + + layout.layout(); + const narrow = compact; + + lane.style.width = '300px'; + layout.layout(); + const expandedAfterLaneGrows = compact; + + lane.style.width = '120px'; + expandedWidth = 80; + layout.layout(); + const wideEnoughForCurrentItems = compact; + + assert.deepStrictEqual({ narrow, expandedAfterLaneGrows, wideEnoughForCurrentItems }, { + narrow: true, + expandedAfterLaneGrows: false, + wideEnoughForCurrentItems: false, + }); + }); + + test('compacts picker items from right to left until the lane fits', () => { + const lane = dom.append(host, dom.$('.picker-lane')); + lane.style.display = 'flex'; + lane.style.width = '180px'; + lane.style.overflow = 'hidden'; + + const compact = [false, false, false]; + const pickers = compact.map((_, index) => { + const picker = dom.append(lane, dom.$(`.picker-${index}`)); + picker.style.flex = '0 0 auto'; + picker.style.width = '80px'; + return picker; + }); + const layout = store.add(new ChatInputPickerResponsiveLayout('test.progressivePickerLane', lane, { + getItems: () => pickers.map((picker, index) => ({ + element: picker, + isCompact: () => compact[index], + setCompact: value => { + compact[index] = value; + picker.style.width = value ? '20px' : '80px'; + }, + })), + })); + + layout.layout(); + const firstCollision = [...compact]; + + lane.style.width = '130px'; + layout.layout(); + const secondCollision = [...compact]; + + lane.style.width = '240px'; + layout.layout(); + const expanded = [...compact]; + + assert.deepStrictEqual({ firstCollision, secondCollision, expanded }, { + firstCollision: [false, false, true], + secondCollision: [false, true, true], + expanded: [false, false, false], + }); + }); + + test('treats an empty picker set as fully compact', () => { + const lane = dom.append(host, dom.$('.picker-lane')); + const layout = store.add(new ChatInputPickerResponsiveLayout('test.emptyPickerLane', lane, { + getItems: () => [], + })); + + assert.strictEqual(layout.areAllItemsCompact(), true); + }); + + test('ignores mutations outside the responsive picker container', async () => { + const row = dom.append(host, dom.$('.secondary-row')); + const lane = dom.append(row, dom.$('.responsive-picker-container')); + const picker = dom.append(lane, dom.$('.picker')); + const unrelated = dom.append(row, dom.$('.context-usage')); + lane.style.width = '100px'; + lane.style.height = '20px'; + let compact = false; + const layout = store.add(new ChatInputPickerResponsiveLayout('test.isolatedPickerLane', lane, { + getItems: () => [{ + element: picker, + isCompact: () => compact, + setCompact: value => compact = value, + }], + })); + + let layoutCalls = 0; + layout.layout = () => layoutCalls++; + const targetWindow = dom.getWindow(lane); + await new Promise(resolve => targetWindow.requestAnimationFrame(() => targetWindow.requestAnimationFrame(() => resolve()))); + layoutCalls = 0; + unrelated.textContent = 'streamed cost update'; + await new Promise(resolve => setTimeout(resolve, 0)); + const afterUnrelatedMutation = layoutCalls; + + picker.textContent = 'picker changed'; + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.strictEqual(afterUnrelatedMutation, 0); + assert.ok(layoutCalls > 0); + }); + + test('restores overflowed actions in compact form before considering expanded labels', () => { + const lane = dom.append(host, dom.$('.picker-lane')); + lane.style.display = 'flex'; + lane.style.width = '50px'; + lane.style.overflow = 'hidden'; + + const actionBar = dom.append(lane, dom.$('.monaco-action-bar.has-overflow')); + const picker = dom.append(actionBar, dom.$('.picker')); + let compact = false; + let overflow = true; + const layout = store.add(new ChatInputPickerResponsiveLayout('test.overflowedPickerLane', lane, { + getItems: () => [{ + element: picker, + isCompact: () => compact, + setCompact: value => { + compact = value; + picker.style.width = value ? '60px' : '150px'; + }, + }], + hasOverflow: () => overflow, + relayout: () => { + overflow = picker.getBoundingClientRect().width > lane.getBoundingClientRect().width; + }, + })); + + layout.layout(); + const tooNarrowForCompact = { compact, overflow }; + + lane.style.width = '70px'; + layout.layout(); + const compactItemsRestored = { compact, overflow }; + + lane.style.width = '160px'; + layout.layout(); + const expanded = { compact, overflow }; + + assert.deepStrictEqual({ tooNarrowForCompact, compactItemsRestored, expanded }, { + tooNarrowForCompact: { compact: true, overflow: true }, + compactItemsRestored: { compact: true, overflow: false }, + expanded: { compact: false, overflow: false }, + }); + }); + + test('compacts a picker whose rendered bounds escape the lane', () => { + const lane = dom.append(host, dom.$('.picker-lane')); + lane.style.display = 'flex'; + lane.style.width = '100px'; + lane.style.overflow = 'visible'; + + const picker = dom.append(lane, dom.$('.picker')); + picker.style.flex = '0 0 auto'; + picker.style.width = '80px'; + picker.style.transform = 'translateX(50px)'; + let compact = false; + const layout = store.add(new ChatInputPickerResponsiveLayout('test.visuallyOverflowedPickerLane', lane, { + getItems: () => [{ + element: picker, + isCompact: () => compact, + setCompact: value => { + compact = value; + picker.style.width = value ? '20px' : '80px'; + }, + }], + })); + + layout.layout(); + + assert.deepStrictEqual({ + compact, + measurementHosts: host.querySelectorAll('.chat-input-picker-measurement-host').length, + }, { + compact: true, + measurementHosts: 0, + }); + }); + + test('compacts an expanded picker before its label truncates', () => { + const lane = dom.append(host, dom.$('.picker-lane')); + lane.style.display = 'flex'; + lane.style.width = '200px'; + + const picker = dom.append(lane, dom.$('.picker')); + picker.style.flex = '0 1 80px'; + picker.style.width = '80px'; + picker.style.overflow = 'hidden'; + const label = dom.append(picker, dom.$('.picker-label')); + label.style.display = 'block'; + label.style.width = '140px'; + label.textContent = 'A picker label that would otherwise ellipsize'; + + let compact = false; + const layout = store.add(new ChatInputPickerResponsiveLayout('test.truncatedPickerLane', lane, { + getItems: () => [{ + element: picker, + isCompact: () => compact, + setCompact: value => { + compact = value; + picker.style.width = value ? '20px' : '80px'; + label.style.display = value ? 'none' : ''; + }, + }], + })); + + layout.layout(); + + assert.strictEqual(compact, true); + }); + + test('keeps the toolbar row height stable when the model picker overflows', () => { + host.style.setProperty('--vscode-spacing-size40', '4px'); + host.style.setProperty('--vscode-spacing-size60', '6px'); + host.classList.add('interactive-session'); + + const row = dom.append(host, dom.$('.picker-row.chat-input-toolbar')); + row.style.display = 'flex'; + row.style.alignItems = 'center'; + + const modelItem = dom.append(row, dom.$('.chat-input-picker-item')); + const modelLabel = dom.append(modelItem, dom.$('a.action-label.model-picker-split')); + const modelName = dom.append(modelLabel, dom.$('.model-picker-section.model-picker-name')); + const pickerLabel = dom.append(modelName, dom.$('.chat-input-picker-label')); + + const overflowItem = dom.append(row, dom.$('.overflow-item')); + overflowItem.style.width = '22px'; + overflowItem.style.height = '22px'; + overflowItem.style.display = 'none'; + + const withModelPicker = row.getBoundingClientRect().height; + const expandedIconOffset = modelName.getBoundingClientRect().left - modelLabel.getBoundingClientRect().left; + modelLabel.style.width = '22px'; + modelItem.classList.add('compact-picker'); + modelLabel.classList.add('compact'); + const compactIconOffset = modelName.getBoundingClientRect().left - modelLabel.getBoundingClientRect().left; + modelItem.style.display = 'none'; + overflowItem.style.display = ''; + const withOverflow = row.getBoundingClientRect().height; + + assert.deepStrictEqual({ + withModelPicker, + withOverflow, + modelNameFlexShrink: dom.getWindow(modelName).getComputedStyle(modelName).flexShrink, + labelTextOverflow: dom.getWindow(pickerLabel).getComputedStyle(pickerLabel).textOverflow, + expandedIconOffset, + compactIconOffset, + }, { + withModelPicker: 22, + withOverflow: 22, + modelNameFlexShrink: '0', + labelTextOverflow: 'clip', + expandedIconOffset: 0, + compactIconOffset: 0, + }); + }); + + test('keeps the primary picker icon anchored when its label disappears', () => { + host.style.setProperty('--vscode-spacing-size60', '6px'); + host.classList.add('interactive-session'); + const toolbar = dom.append(host, dom.$('.chat-input-toolbar')); + const item = dom.append(toolbar, dom.$('.chat-input-picker-item')); + const actionLabel = dom.append(item, dom.$('a.action-label')); + const icon = dom.append(actionLabel, dom.$('span.codicon')); + icon.style.width = '16px'; + icon.style.height = '16px'; + const pickerLabel = dom.append(actionLabel, dom.$('span.chat-input-picker-label')); + pickerLabel.textContent = 'Picker'; + + const expandedOffset = icon.getBoundingClientRect().left - actionLabel.getBoundingClientRect().left; + item.classList.add('compact'); + actionLabel.classList.add('icon-only'); + pickerLabel.remove(); + const compactOffset = icon.getBoundingClientRect().left - actionLabel.getBoundingClientRect().left; + + assert.deepStrictEqual({ expandedOffset, compactOffset }, { + expandedOffset: 6, + compactOffset: 6, + }); + }); +}); diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index bf64b86f8cf..c4c1c6a1a2d 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -79,10 +79,10 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/7f70224f7733a2461eba63fa98234aab38b8804a73460deffa11f49cd6f7172c) #### editor/inlineChatZoneWidget/InlineChatZoneWidget/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/f9e5bfb616a989cd170f3aafd172838918c8f40fff0cbbcd5c595cc2405de2dc) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/9a23d68d520d31525d56d8cfb444365a79f81b0ee3391de28e94bf32004ef778) #### editor/inlineChatZoneWidget/InlineChatZoneWidget/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/385d01a93004536ce28abfc8d99812dc43e08db035983a72d3d762a6754f29ce) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/0907edfda4ec6bb22618a9aa1d3ef106233edde64145f0c4e5ec0c3b14fcbf24) #### editor/inlineChatZoneWidget/InlineChatZoneWidgetTerminated/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/0752cf02ae3a4e21fce84b62859df32a5f41c13622bdec0083a3fd46832c2e0a) From 4e4877b113b1c34f3f01f08138038fe564e84171 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Tue, 25 Aug 2026 23:29:24 -0700 Subject: [PATCH 038/116] Avoid terminal link provider listeners for detached terminals (#332470) * Avoid detached terminal link provider listeners Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Isolate terminal link contribution tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/terminal.links.contribution.ts | 87 +------------ .../links/browser/terminalLinkContribution.ts | 86 +++++++++++++ .../browser/terminalLinkContribution.test.ts | 116 ++++++++++++++++++ 3 files changed, 204 insertions(+), 85 deletions(-) create mode 100644 src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkContribution.ts create mode 100644 src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkContribution.test.ts diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/terminal.links.contribution.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/terminal.links.contribution.ts index bdea55b6466..94733ef7481 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/browser/terminal.links.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/browser/terminal.links.contribution.ts @@ -3,29 +3,21 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { Terminal as RawXtermTerminal } from '@xterm/xterm'; -import { Event } from '../../../../../base/common/event.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; -import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { localize2 } from '../../../../../nls.js'; import { AccessibleViewProviderId } from '../../../../../platform/accessibility/browser/accessibleView.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; -import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { accessibleViewCurrentProviderId, accessibleViewIsShown } from '../../../accessibility/browser/accessibilityConfiguration.js'; -import { ITerminalContribution, ITerminalInstance, IXtermTerminal, isDetachedTerminalInstance } from '../../../terminal/browser/terminal.js'; import { registerActiveInstanceAction } from '../../../terminal/browser/terminalActions.js'; -import { registerTerminalContribution, type IDetachedCompatibleTerminalContributionContext, type ITerminalContributionContext } from '../../../terminal/browser/terminalExtensions.js'; -import { isTerminalProcessManager } from '../../../terminal/common/terminal.js'; +import { registerTerminalContribution } from '../../../terminal/browser/terminalExtensions.js'; import { TerminalContextKeys } from '../../../terminal/common/terminalContextKey.js'; import { terminalStrings } from '../../../terminal/common/terminalStrings.js'; import { TerminalLinksCommandId } from '../common/terminal.links.js'; import { ITerminalLinkProviderService } from './links.js'; -import { IDetectedLinks, TerminalLinkManager } from './terminalLinkManager.js'; +import { TerminalLinkContribution } from './terminalLinkContribution.js'; import { TerminalLinkProviderService } from './terminalLinkProviderService.js'; -import { TerminalLinkQuickpick } from './terminalLinkQuickpick.js'; -import { TerminalLinkResolver } from './terminalLinkResolver.js'; // #region Services @@ -33,83 +25,8 @@ registerSingleton(ITerminalLinkProviderService, TerminalLinkProviderService, Ins // #endregion -// #region Terminal Contributions - -class TerminalLinkContribution extends DisposableStore implements ITerminalContribution { - static readonly ID = 'terminal.link'; - - static get(instance: ITerminalInstance): TerminalLinkContribution | null { - return instance.getContribution(TerminalLinkContribution.ID); - } - - private _linkManager: TerminalLinkManager | undefined; - private _terminalLinkQuickpick: TerminalLinkQuickpick | undefined; - private _linkResolver: TerminalLinkResolver; - - constructor( - private readonly _ctx: ITerminalContributionContext | IDetachedCompatibleTerminalContributionContext, - @IInstantiationService private readonly _instantiationService: IInstantiationService, - @ITerminalLinkProviderService private readonly _terminalLinkProviderService: ITerminalLinkProviderService, - ) { - super(); - this._linkResolver = this._instantiationService.createInstance(TerminalLinkResolver); - } - - xtermReady(xterm: IXtermTerminal & { raw: RawXtermTerminal }): void { - const linkManager = this._linkManager = this.add(this._instantiationService.createInstance(TerminalLinkManager, xterm.raw, this._ctx.processManager, this._ctx.instance.capabilities, this._linkResolver)); - - // Set widget manager - if (isTerminalProcessManager(this._ctx.processManager)) { - const disposable = linkManager.add(Event.once(this._ctx.processManager.onProcessReady)(() => { - linkManager.setWidgetManager(this._ctx.widgetManager); - this.delete(disposable); - })); - } else { - linkManager.setWidgetManager(this._ctx.widgetManager); - } - - // Attach the external link provider to the instance and listen for changes - if (!isDetachedTerminalInstance(this._ctx.instance)) { - for (const linkProvider of this._terminalLinkProviderService.linkProviders) { - linkManager.externalProvideLinksCb = linkProvider.provideLinks.bind(linkProvider, this._ctx.instance); - } - linkManager.add(this._terminalLinkProviderService.onDidAddLinkProvider(e => { - linkManager.externalProvideLinksCb = e.provideLinks.bind(e, this._ctx.instance as ITerminalInstance); - })); - } - linkManager.add(this._terminalLinkProviderService.onDidRemoveLinkProvider(() => linkManager.externalProvideLinksCb = undefined)); - } - - async showLinkQuickpick(extended?: boolean): Promise { - if (!this._terminalLinkQuickpick) { - this._terminalLinkQuickpick = this.add(this._instantiationService.createInstance(TerminalLinkQuickpick)); - this.add(this._terminalLinkQuickpick.onDidRequestMoreLinks(() => { - this.showLinkQuickpick(true); - })); - } - const links = await this._getLinks(); - return await this._terminalLinkQuickpick.show(this._ctx.instance, links); - } - - private async _getLinks(): Promise<{ viewport: IDetectedLinks; all: Promise }> { - if (!this._linkManager) { - throw new Error('terminal links are not ready, cannot generate link quick pick'); - } - return this._linkManager.getLinks(); - } - - async openRecentLink(type: 'localFile' | 'url'): Promise { - if (!this._linkManager) { - throw new Error('terminal links are not ready, cannot open a link'); - } - this._linkManager.openRecentLink(type); - } -} - registerTerminalContribution(TerminalLinkContribution.ID, TerminalLinkContribution, true); -// #endregion - // #region Actions const category = terminalStrings.actionCategory; diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkContribution.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkContribution.ts new file mode 100644 index 00000000000..a59bc4a89c8 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkContribution.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Terminal as RawXtermTerminal } from '@xterm/xterm'; +import { Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ITerminalContribution, ITerminalInstance, IXtermTerminal, isDetachedTerminalInstance } from '../../../terminal/browser/terminal.js'; +import { IDetachedCompatibleTerminalContributionContext, ITerminalContributionContext } from '../../../terminal/browser/terminalExtensions.js'; +import { isTerminalProcessManager } from '../../../terminal/common/terminal.js'; +import { ITerminalLinkProviderService } from './links.js'; +import { IDetectedLinks, TerminalLinkManager } from './terminalLinkManager.js'; +import { TerminalLinkQuickpick } from './terminalLinkQuickpick.js'; +import { TerminalLinkResolver } from './terminalLinkResolver.js'; + +export class TerminalLinkContribution extends DisposableStore implements ITerminalContribution { + static readonly ID = 'terminal.link'; + + static get(instance: ITerminalInstance): TerminalLinkContribution | null { + return instance.getContribution(TerminalLinkContribution.ID); + } + + private _linkManager: TerminalLinkManager | undefined; + private _terminalLinkQuickpick: TerminalLinkQuickpick | undefined; + private _linkResolver: TerminalLinkResolver; + + constructor( + private readonly _ctx: ITerminalContributionContext | IDetachedCompatibleTerminalContributionContext, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @ITerminalLinkProviderService private readonly _terminalLinkProviderService: ITerminalLinkProviderService, + ) { + super(); + this._linkResolver = this._instantiationService.createInstance(TerminalLinkResolver); + } + + xtermReady(xterm: IXtermTerminal & { raw: RawXtermTerminal }): void { + const linkManager = this._linkManager = this.add(this._instantiationService.createInstance(TerminalLinkManager, xterm.raw, this._ctx.processManager, this._ctx.instance.capabilities, this._linkResolver)); + + if (isTerminalProcessManager(this._ctx.processManager)) { + const disposable = linkManager.add(Event.once(this._ctx.processManager.onProcessReady)(() => { + linkManager.setWidgetManager(this._ctx.widgetManager); + this.delete(disposable); + })); + } else { + linkManager.setWidgetManager(this._ctx.widgetManager); + } + + const instance = this._ctx.instance; + if (!isDetachedTerminalInstance(instance)) { + for (const linkProvider of this._terminalLinkProviderService.linkProviders) { + linkManager.externalProvideLinksCb = linkProvider.provideLinks.bind(linkProvider, instance); + } + linkManager.add(this._terminalLinkProviderService.onDidAddLinkProvider(e => { + linkManager.externalProvideLinksCb = e.provideLinks.bind(e, instance); + })); + linkManager.add(this._terminalLinkProviderService.onDidRemoveLinkProvider(() => linkManager.externalProvideLinksCb = undefined)); + } + } + + async showLinkQuickpick(extended?: boolean): Promise { + if (!this._terminalLinkQuickpick) { + this._terminalLinkQuickpick = this.add(this._instantiationService.createInstance(TerminalLinkQuickpick)); + this.add(this._terminalLinkQuickpick.onDidRequestMoreLinks(() => { + this.showLinkQuickpick(true); + })); + } + const links = await this._getLinks(); + return await this._terminalLinkQuickpick.show(this._ctx.instance, links); + } + + private async _getLinks(): Promise<{ viewport: IDetectedLinks; all: Promise }> { + if (!this._linkManager) { + throw new Error('terminal links are not ready, cannot generate link quick pick'); + } + return this._linkManager.getLinks(); + } + + async openRecentLink(type: 'localFile' | 'url'): Promise { + if (!this._linkManager) { + throw new Error('terminal links are not ready, cannot open a link'); + } + this._linkManager.openRecentLink(type); + } +} diff --git a/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkContribution.test.ts b/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkContribution.test.ts new file mode 100644 index 00000000000..5ba5a16397b --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkContribution.test.ts @@ -0,0 +1,116 @@ +/*--------------------------------------------------------------------------------------------- + * 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 type { Terminal as RawXtermTerminal } from '@xterm/xterm'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { DisposableStore, IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { TerminalCapabilityStore } from '../../../../../../platform/terminal/common/capabilities/terminalCapabilityStore.js'; +import { IDetachedTerminalInstance, ITerminalExternalLinkProvider, ITerminalInstance, IXtermTerminal } from '../../../../terminal/browser/terminal.js'; +import { IDetachedCompatibleTerminalContributionContext, ITerminalContributionContext } from '../../../../terminal/browser/terminalExtensions.js'; +import { TerminalWidgetManager } from '../../../../terminal/browser/widgets/widgetManager.js'; +import { ITerminalProcessInfo, ITerminalProcessManager } from '../../../../terminal/common/terminal.js'; +import { ITerminalLinkProviderService } from '../../browser/links.js'; +import { TerminalLinkContribution } from '../../browser/terminalLinkContribution.js'; +import { TerminalLinkManager } from '../../browser/terminalLinkManager.js'; +import { TerminalLinkResolver } from '../../browser/terminalLinkResolver.js'; + +function listenerCount(emitter: Emitter): number { + return (emitter as unknown as { _size: number })._size ?? 0; +} + +suite('TerminalLinkContribution', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let instantiationService: TestInstantiationService; + let onDidAddLinkProvider: Emitter; + let onDidRemoveLinkProvider: Emitter; + let xterm: IXtermTerminal & { raw: RawXtermTerminal }; + + setup(() => { + instantiationService = store.add(new TestInstantiationService()); + onDidAddLinkProvider = store.add(new Emitter()); + onDidRemoveLinkProvider = store.add(new Emitter()); + instantiationService.stub(ITerminalLinkProviderService, new class extends mock() { + override readonly linkProviders = new Set(); + override readonly onDidAddLinkProvider = onDidAddLinkProvider.event; + override readonly onDidRemoveLinkProvider = onDidRemoveLinkProvider.event; + }()); + instantiationService.stubInstance(TerminalLinkResolver, {}); + + const linkManagerStore = store.add(new DisposableStore()); + instantiationService.stubInstance(TerminalLinkManager, { + add: (disposable: T) => linkManagerStore.add(disposable), + setWidgetManager: () => { }, + dispose: () => linkManagerStore.dispose(), + }); + xterm = Object.assign(Object.create(null) as IXtermTerminal & { raw: RawXtermTerminal }, { + raw: Object.create(null) as RawXtermTerminal, + }); + }); + + function createContribution(detached: boolean) { + const capabilities = store.add(new TerminalCapabilityStore()); + const widgetManager = Object.create(TerminalWidgetManager.prototype) as TerminalWidgetManager; + const context: IDetachedCompatibleTerminalContributionContext | ITerminalContributionContext = detached + ? { + instance: Object.assign(Object.create(null) as IDetachedTerminalInstance, { capabilities }), + processManager: Object.create(null) as ITerminalProcessInfo, + widgetManager, + } + : { + instance: Object.assign(Object.create(null) as ITerminalInstance, { capabilities, instanceId: 1 }), + processManager: Object.create(null) as ITerminalProcessManager, + widgetManager, + }; + const contribution = store.add(instantiationService.createInstance(TerminalLinkContribution, context)); + contribution.xtermReady?.(xterm); + return contribution; + } + + test('does not register external link provider listeners for detached terminals', () => { + for (let index = 0; index < 50; index++) { + createContribution(true); + } + + assert.deepStrictEqual({ + addedListeners: listenerCount(onDidAddLinkProvider), + removedListeners: listenerCount(onDidRemoveLinkProvider), + }, { + addedListeners: 0, + removedListeners: 0, + }); + }); + + test('registers and disposes external link provider listeners for regular terminals', () => { + const contribution = createContribution(false); + const countsAfterRegistration = { + addedListeners: listenerCount(onDidAddLinkProvider), + removedListeners: listenerCount(onDidRemoveLinkProvider), + }; + + contribution.dispose(); + + assert.deepStrictEqual({ + countsAfterRegistration, + countsAfterDispose: { + addedListeners: listenerCount(onDidAddLinkProvider), + removedListeners: listenerCount(onDidRemoveLinkProvider), + }, + }, { + countsAfterRegistration: { + addedListeners: 1, + removedListeners: 1, + }, + countsAfterDispose: { + addedListeners: 0, + removedListeners: 0, + }, + }); + }); +}); From 33fcbf32e37406fa942adf7e986a24924afc5fcd Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Tue, 25 Aug 2026 11:24:27 +0200 Subject: [PATCH 039/116] Update os-proxy-resolver to 0.4.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/linux/debian/dep-lists.ts | 1 + package-lock.json | 80 ++++++++++++++++----------------- package.json | 2 +- 3 files changed, 42 insertions(+), 41 deletions(-) diff --git a/build/linux/debian/dep-lists.ts b/build/linux/debian/dep-lists.ts index 7db8c4b5dde..eb8b42624b2 100644 --- a/build/linux/debian/dep-lists.ts +++ b/build/linux/debian/dep-lists.ts @@ -29,6 +29,7 @@ export const referenceGeneratedDepsByArch = { 'libatk-bridge2.0-0 (>= 2.5.3)', 'libatk1.0-0 (>= 2.11.90)', 'libatspi2.0-0 (>= 2.9.90)', + 'libc6 (>= 2.15)', 'libc6 (>= 2.16)', 'libc6 (>= 2.17)', 'libc6 (>= 2.2.5)', diff --git a/package-lock.json b/package-lock.json index c91a44b5d23..78931c5a60e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "@vscode/fs-copyfile": "2.0.0", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/os-proxy-resolver": "^0.3.0", + "@vscode/os-proxy-resolver": "^0.4.0", "@vscode/policy-watcher": "^1.4.0", "@vscode/proxy-agent": "^0.44.0", "@vscode/ripgrep-universal": "^1.18.0", @@ -4941,29 +4941,29 @@ "license": "MIT" }, "node_modules/@vscode/os-proxy-resolver": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@vscode/os-proxy-resolver/-/os-proxy-resolver-0.3.0.tgz", - "integrity": "sha512-JUDgHj8DQKc0h8rwYl+o1fG3ZXOJ7KuzqR+0T6LMQ39OnljBRpok/eBv/dtnx6kQF3++TkO2UASuRVypU4tSmg==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@vscode/os-proxy-resolver/-/os-proxy-resolver-0.4.0.tgz", + "integrity": "sha512-bSwx24M7okqbI7B57jfARm/ICCPB4w22lXk5YlROmzJ3j5ponIiya23OqMwFNFLtoQktWkbpxxafVL0BBpbRQw==", "license": "MIT", "engines": { "node": ">=22.15.0" }, "optionalDependencies": { - "@vscode/os-proxy-resolver-darwin-arm64": "0.3.0", - "@vscode/os-proxy-resolver-darwin-x64": "0.3.0", - "@vscode/os-proxy-resolver-linux-arm-gnueabihf": "0.3.0", - "@vscode/os-proxy-resolver-linux-arm64-gnu": "0.3.0", - "@vscode/os-proxy-resolver-linux-arm64-musl": "0.3.0", - "@vscode/os-proxy-resolver-linux-x64-gnu": "0.3.0", - "@vscode/os-proxy-resolver-linux-x64-musl": "0.3.0", - "@vscode/os-proxy-resolver-win32-arm64-msvc": "0.3.0", - "@vscode/os-proxy-resolver-win32-x64-msvc": "0.3.0" + "@vscode/os-proxy-resolver-darwin-arm64": "0.4.0", + "@vscode/os-proxy-resolver-darwin-x64": "0.4.0", + "@vscode/os-proxy-resolver-linux-arm-gnueabihf": "0.4.0", + "@vscode/os-proxy-resolver-linux-arm64-gnu": "0.4.0", + "@vscode/os-proxy-resolver-linux-arm64-musl": "0.4.0", + "@vscode/os-proxy-resolver-linux-x64-gnu": "0.4.0", + "@vscode/os-proxy-resolver-linux-x64-musl": "0.4.0", + "@vscode/os-proxy-resolver-win32-arm64-msvc": "0.4.0", + "@vscode/os-proxy-resolver-win32-x64-msvc": "0.4.0" } }, "node_modules/@vscode/os-proxy-resolver-darwin-arm64": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@vscode/os-proxy-resolver-darwin-arm64/-/os-proxy-resolver-darwin-arm64-0.3.0.tgz", - "integrity": "sha512-ef9rbWVDdouTd++dKLxqw65o2D8nkMkf1LxiIvUX/S/6DzvuJQrQhqixNckC63kH3bDdV/LONF+HvrG7gcHxYQ==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@vscode/os-proxy-resolver-darwin-arm64/-/os-proxy-resolver-darwin-arm64-0.4.0.tgz", + "integrity": "sha512-QNYOWxTaQ9D/rUf88LjOPYAkMXsX2yGvECW/6Ii0BO5gBr7lZZRDa7lfYqjjcmIiJoxxu/83Wi1pXlB0ZT9UDw==", "cpu": [ "arm64" ], @@ -4974,9 +4974,9 @@ ] }, "node_modules/@vscode/os-proxy-resolver-darwin-x64": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@vscode/os-proxy-resolver-darwin-x64/-/os-proxy-resolver-darwin-x64-0.3.0.tgz", - "integrity": "sha512-NrJPt2OMXr7nn0XfI0i6eikrI6R012qxWXTvshFRFiOyLkdUVNRqwOpkqWlNEQuTwyqyOxe9E6QFVd6XgaF6YA==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@vscode/os-proxy-resolver-darwin-x64/-/os-proxy-resolver-darwin-x64-0.4.0.tgz", + "integrity": "sha512-nV9DTpit6VoVLPOpIr9NVWfDSgql8C62PWkVLumCANF5xqLsHyKjuX/UJ53/WPmOl7Sf7Z+J4tNzQpGSCdV8fg==", "cpu": [ "x64" ], @@ -4987,9 +4987,9 @@ ] }, "node_modules/@vscode/os-proxy-resolver-linux-arm-gnueabihf": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@vscode/os-proxy-resolver-linux-arm-gnueabihf/-/os-proxy-resolver-linux-arm-gnueabihf-0.3.0.tgz", - "integrity": "sha512-lrHXnRuTZcQUdI7p7T+usSRbcnMsLHoE+F8VTQsw1DWBQXTKA3J2SXZcCf9nxNIagc/u7cSSozhFIqULqnW8Ig==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@vscode/os-proxy-resolver-linux-arm-gnueabihf/-/os-proxy-resolver-linux-arm-gnueabihf-0.4.0.tgz", + "integrity": "sha512-FOMiZzOe/3IWN/FIGeefF7/R1s3uH7dwqq+4FwBagxhlmEtXfaeJMSInZ56wBrNxXUJXAI/Fsetjm+7eOfCygQ==", "cpu": [ "arm" ], @@ -5003,9 +5003,9 @@ ] }, "node_modules/@vscode/os-proxy-resolver-linux-arm64-gnu": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@vscode/os-proxy-resolver-linux-arm64-gnu/-/os-proxy-resolver-linux-arm64-gnu-0.3.0.tgz", - "integrity": "sha512-CmQPXcjfrfvVQ446dGxeITtsGwd58kO0j207vYBjamUjYMgi1frltKPeOIYHgklJwGVQZNwqVBUIS5d45VE1Bg==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@vscode/os-proxy-resolver-linux-arm64-gnu/-/os-proxy-resolver-linux-arm64-gnu-0.4.0.tgz", + "integrity": "sha512-YG9NIXolWmD7x5aLWPGP1UQKfErNonHBT9fSvKuDL6DspGyQJFKkyqudTzHu1ykSgwb1VuWmMSckQQoFhYL6ew==", "cpu": [ "arm64" ], @@ -5019,9 +5019,9 @@ ] }, "node_modules/@vscode/os-proxy-resolver-linux-arm64-musl": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@vscode/os-proxy-resolver-linux-arm64-musl/-/os-proxy-resolver-linux-arm64-musl-0.3.0.tgz", - "integrity": "sha512-3mo9+dkB5BjnEnmLWqYkO2X58JwqWgkxDHJoFf7qfTpOV6YfqoqFnbNqLKfjQU9BBRSYg3ixcLhlkmmfMRNXtw==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@vscode/os-proxy-resolver-linux-arm64-musl/-/os-proxy-resolver-linux-arm64-musl-0.4.0.tgz", + "integrity": "sha512-DnQ/dGjJoQeWgWRqGtXoJlAW1NIbewG5BmbvM8F+w5edgiTw2zLKwVpDVfaERg/Gkn+vXegfzyhOpjPaNyfXIQ==", "cpu": [ "arm64" ], @@ -5035,9 +5035,9 @@ ] }, "node_modules/@vscode/os-proxy-resolver-linux-x64-gnu": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@vscode/os-proxy-resolver-linux-x64-gnu/-/os-proxy-resolver-linux-x64-gnu-0.3.0.tgz", - "integrity": "sha512-ymoShcbOV85b/rrzemidN6o5LJN9h+Au/h3eJhgJwRYV5+dP+JTjSMUQ5nOMHUARYFISzOO7weP2jW5hCsNlMg==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@vscode/os-proxy-resolver-linux-x64-gnu/-/os-proxy-resolver-linux-x64-gnu-0.4.0.tgz", + "integrity": "sha512-aIginNUW2UjUDqiDj15PlGz4qzlWZHsNnpcTF6dHMePOGvgjJYdy7I7jNE7Itg6tkTwEh+OY0L2dyUqHRGlw9g==", "cpu": [ "x64" ], @@ -5051,9 +5051,9 @@ ] }, "node_modules/@vscode/os-proxy-resolver-linux-x64-musl": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@vscode/os-proxy-resolver-linux-x64-musl/-/os-proxy-resolver-linux-x64-musl-0.3.0.tgz", - "integrity": "sha512-FnFEBsLeOSgTkA9mE+ezOksWBVoFaz5hJZbpNegAAMURpEGqdwtDl62InD3LGODhRd55qpVoG5HZ7c8CJZO9cA==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@vscode/os-proxy-resolver-linux-x64-musl/-/os-proxy-resolver-linux-x64-musl-0.4.0.tgz", + "integrity": "sha512-3f7tzRzyUmT4N9pmdY2mJV1pntkRk61UsB41/MqzI50C+N3byIAmB3HkhYtLPibDNfH8t5ISTl/vOc3ixpk1bw==", "cpu": [ "x64" ], @@ -5067,9 +5067,9 @@ ] }, "node_modules/@vscode/os-proxy-resolver-win32-arm64-msvc": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@vscode/os-proxy-resolver-win32-arm64-msvc/-/os-proxy-resolver-win32-arm64-msvc-0.3.0.tgz", - "integrity": "sha512-+rsd9UncPci+H3+HIgkDBZwHFuVOcdtv9jEG5rKgYMzhJq9KigTfjy/KtHpK9awKyAM48Qga9xvw3dediatS0Q==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@vscode/os-proxy-resolver-win32-arm64-msvc/-/os-proxy-resolver-win32-arm64-msvc-0.4.0.tgz", + "integrity": "sha512-g9kDAbc/PrNdWArYvJmEwrKInZzvmizV83dc2lisHqFa9g9Iq6nFT7KJMVfVjJ6ZmhijuwCYpu91ikjNMnC5Wg==", "cpu": [ "arm64" ], @@ -5080,9 +5080,9 @@ ] }, "node_modules/@vscode/os-proxy-resolver-win32-x64-msvc": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@vscode/os-proxy-resolver-win32-x64-msvc/-/os-proxy-resolver-win32-x64-msvc-0.3.0.tgz", - "integrity": "sha512-qWQeaiPNDTJYErdq90HTrgMI5Fl814PwN3wtHvEMK2qRvT1CiRfBnkv5Mj3H4TfXMINsA5rr39pygwys0e0PWg==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@vscode/os-proxy-resolver-win32-x64-msvc/-/os-proxy-resolver-win32-x64-msvc-0.4.0.tgz", + "integrity": "sha512-35NrEYB8MKug3dui465JAV83lHi8pKeM7T4yUNLLAG7z/zFO3fpbB7FgQ+rh4AyZF9QwPvEvy8UgYqXmjA36aw==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 0d0f2eedd16..add5332642d 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,7 @@ "@vscode/fs-copyfile": "2.0.0", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/os-proxy-resolver": "^0.3.0", + "@vscode/os-proxy-resolver": "^0.4.0", "@vscode/policy-watcher": "^1.4.0", "@vscode/proxy-agent": "^0.44.0", "@vscode/ripgrep-universal": "^1.18.0", From 58c36c04682512432acc42616a66708134830e97 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Tue, 25 Aug 2026 22:46:58 +0200 Subject: [PATCH 040/116] Move Copilot test cache pull before build Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/azure-pipelines/copilot/pull-test-cache.yml | 14 ++++++++++++++ .../copilot/test-integration-steps.yml | 13 ------------- .../darwin/steps/product-build-darwin-compile.yml | 3 +++ .../linux/steps/product-build-linux-compile.yml | 3 +++ .../win32/steps/product-build-win32-compile.yml | 3 +++ 5 files changed, 23 insertions(+), 13 deletions(-) create mode 100644 build/azure-pipelines/copilot/pull-test-cache.yml diff --git a/build/azure-pipelines/copilot/pull-test-cache.yml b/build/azure-pipelines/copilot/pull-test-cache.yml new file mode 100644 index 00000000000..3b2ff664402 --- /dev/null +++ b/build/azure-pipelines/copilot/pull-test-cache.yml @@ -0,0 +1,14 @@ +# Keep this immediately after checkout: the GitHub credential expires after one hour, and later steps must not restore the LFS pointer files. +steps: + - script: git lfs install --local + displayName: Initialize Git LFS + + - script: git lfs pull --include="extensions/copilot/test/simulation/cache/**" + condition: and(succeeded(), eq(variables['Build.Repository.Provider'], 'GitHub')) + displayName: Pull Copilot test cache from GitHub + + - script: git --config-env=http.extraheader=GIT_AUTH_HEADER lfs pull --include="extensions/copilot/test/simulation/cache/**" + condition: and(succeeded(), eq(variables['Build.Repository.Provider'], 'TfsGit')) + displayName: Pull Copilot test cache from Azure Repos + env: + GIT_AUTH_HEADER: "AUTHORIZATION: bearer $(System.AccessToken)" diff --git a/build/azure-pipelines/copilot/test-integration-steps.yml b/build/azure-pipelines/copilot/test-integration-steps.yml index 6049b243e82..b0d9bf1614c 100644 --- a/build/azure-pipelines/copilot/test-integration-steps.yml +++ b/build/azure-pipelines/copilot/test-integration-steps.yml @@ -3,19 +3,6 @@ parameters: type: string # linux, darwin, win32 steps: - - script: git lfs install --local - displayName: Initialize Git LFS - - - script: git lfs pull --include="extensions/copilot/test/simulation/cache/**" - condition: and(succeeded(), eq(variables['Build.Repository.Provider'], 'GitHub')) - displayName: Pull Copilot test cache from GitHub - - - script: git --config-env=http.extraheader=GIT_AUTH_HEADER lfs pull --include="extensions/copilot/test/simulation/cache/**" - condition: and(succeeded(), eq(variables['Build.Repository.Provider'], 'TfsGit')) - displayName: Pull Copilot test cache from Azure Repos - env: - GIT_AUTH_HEADER: "AUTHORIZATION: bearer $(System.AccessToken)" - # Setup copilot test environment (tokens, env vars) - task: AzureCLI@2 inputs: diff --git a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml index 7033d64c843..4a9d5961039 100644 --- a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml +++ b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml @@ -19,6 +19,9 @@ parameters: steps: - template: ../../common/checkout.yml@self + - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: + - template: ../../copilot/pull-test-cache.yml@self + - task: NodeTool@0 inputs: versionSource: fromFile diff --git a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml index d061b7c0c4e..fcc5663ad7c 100644 --- a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml +++ b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml @@ -27,6 +27,9 @@ parameters: steps: - template: ../../common/checkout.yml@self + - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: + - template: ../../copilot/pull-test-cache.yml@self + - task: NodeTool@0 inputs: versionSource: fromFile diff --git a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml index b5eee1b438f..4f0ee18846f 100644 --- a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml +++ b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml @@ -21,6 +21,9 @@ parameters: steps: - template: ../../common/checkout.yml@self + - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: + - template: ../../copilot/pull-test-cache.yml@self + - task: NodeTool@0 inputs: versionSource: fromFile From 2d2c0c52c6500397b338b1a986138f44379185e9 Mon Sep 17 00:00:00 2001 From: TylerLeonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:26:28 -0700 Subject: [PATCH 041/116] sessions: add dynamic codicon chat backgrounds (#332660) * sessions: add dynamic codicon chat backgrounds Add a theme-aware in-memory Codicons preset alongside image backgrounds. Reconcile Set/Clear/Layout commands across background types, add live layout previews with commit-on-close behavior, and retain a five-image machine-local MRU for quick reuse. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: restore chat background layout on save failure Roll back transient layout state to the configured value when the settings write fails, so an unsaved preview cannot remain active. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: lowercase recently used background label Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: avoid saving cancelled background layout Restore an in-memory layout preview without writing configuration when the picker is cancelled or rejects. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: consolidate chat background updates Use one union-typed service method for both image and built-in background selections while preserving image-only recent history behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/common/contextkeys.ts | 1 + .../contrib/chat/browser/chat.contribution.ts | 128 +++++++++--- .../chat/browser/chatBackgroundRenderer.ts | 141 ++++++++++++++ .../sessions/contrib/chat/browser/chatView.ts | 17 +- .../contrib/chat/browser/media/chatView.css | 41 +++- .../browser/sessionsChatAccessibilityHelp.ts | 2 +- .../chat/test/browser/chatView.test.ts | 59 +++++- .../test/browser/newChatWidget.fixture.ts | 2 +- .../browser/chatBackgroundService.ts | 154 ++++++++++++--- .../browser/chatBackgroundService.test.ts | 183 +++++++++++++++--- 10 files changed, 607 insertions(+), 121 deletions(-) create mode 100644 src/vs/sessions/contrib/chat/browser/chatBackgroundRenderer.ts diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index c199e605d7a..b09132d978a 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -45,6 +45,7 @@ export const SessionHasPullRequestContext = new RawContextKey('sessionH export const SessionHasIssuesContext = new RawContextKey('sessionHasIssues', false, localize('sessionHasIssues', "Whether the session view's session references at least one GitHub issue")); export const SessionHasWorkspaceContext = new RawContextKey('sessionHasWorkspace', false, localize('sessionHasWorkspace', "Whether the session view's session has an associated workspace folder")); export const SessionsChatBackgroundAvailableContext = new RawContextKey('sessionsChatBackgroundAvailable', false, localize('sessionsChatBackgroundAvailable', "Whether chat background customization is available for the current color theme")); +export const SessionsChatBackgroundConfiguredContext = new RawContextKey('sessionsChatBackgroundConfigured', false, localize('sessionsChatBackgroundConfigured', "Whether a chat background is configured for the current color theme")); export const SessionsChatBackgroundImageConfiguredContext = new RawContextKey('sessionsChatBackgroundImageConfigured', false, localize('sessionsChatBackgroundImageConfigured', "Whether a chat background image is configured for the current color theme")); export const IsQuickChatSessionContext = new RawContextKey('isQuickChatSession', false, localize('isQuickChatSession', "Whether the session in scope is a workspace-less quick chat")); diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index 6cb6397bce9..c3e0dbbd4dc 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -6,13 +6,15 @@ import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { Schemas } from '../../../../base/common/network.js'; import { status } from '../../../../base/browser/ui/aria/aria.js'; +import { basename, isEqual } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; -import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; +import { IQuickInputService, IQuickPickItem, QuickPickInput } from '../../../../platform/quickinput/common/quickInput.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ISessionsManagementService, inheritableSessionTarget } from '../../../services/sessions/common/sessionsManagement.js'; @@ -46,19 +48,39 @@ import '../../sessions/browser/mobile/mobileOverlayContribution.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { EditorAreaFocusContext, IsSessionsWindowContext, SideBarVisibleContext } from '../../../../workbench/common/contextkeys.js'; import { NEW_SESSION_ACTION_ID } from '../common/constants.js'; -import { SessionsChatBackgroundAvailableContext, SessionsChatBackgroundImageConfiguredContext, SessionsTitleBarNewSessionEnabledContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { SessionsChatBackgroundAvailableContext, SessionsChatBackgroundConfiguredContext, SessionsChatBackgroundImageConfiguredContext, SessionsTitleBarNewSessionEnabledContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { Menus } from '../../../browser/menus.js'; import { ISessionsChatViewStateService, SessionsChatViewStateService } from './chatViewStateService.js'; import { SessionsChatResponseFileChangesService } from './sessionTurnChanges.js'; import { IChatResponseFileChangesService } from '../../../../workbench/contrib/chat/browser/chatResponseFileChangesService.js'; import { SessionsChatPetAchievementContribution } from './chatPetAchievements.js'; -import { AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING, AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING, chatBackgroundImageLayoutValues, ChatBackgroundImageLayout, ISessionsChatBackgroundService, SessionsChatBackgroundService } from '../../../services/chatBackground/browser/chatBackgroundService.js'; +import { AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET, AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING, AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING, chatBackgroundImageLayoutValues, ChatBackgroundImageLayout, ISessionsChatBackgroundService, SessionsChatBackgroundService } from '../../../services/chatBackground/browser/chatBackgroundService.js'; const CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_COMMAND_ID = 'workbench.action.chat.changeAgentSessionsBackground'; const CLEAR_AGENT_SESSIONS_CHAT_BACKGROUND_COMMAND_ID = 'workbench.action.chat.clearAgentSessionsBackground'; const CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_LAYOUT_COMMAND_ID = 'workbench.action.chat.changeAgentSessionsBackgroundLayout'; const CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN = ContextKeyExpr.and(IsSessionsWindowContext, SessionsChatBackgroundAvailableContext); -const CLEAR_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN = ContextKeyExpr.and(CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN, SessionsChatBackgroundImageConfiguredContext); +const CLEAR_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN = ContextKeyExpr.and(CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN, SessionsChatBackgroundConfiguredContext); +const CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_LAYOUT_WHEN = ContextKeyExpr.and(CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN, SessionsChatBackgroundImageConfiguredContext); + +type RecentChatBackgroundTypeItem = IQuickPickItem & { + readonly kind: 'recentImage'; + readonly image: URI; +}; + +type ChatBackgroundTypeItem = IQuickPickItem & ({ + readonly kind: 'codicons' | 'image'; +}) | RecentChatBackgroundTypeItem; + +const chatBackgroundTypeItems: ChatBackgroundTypeItem[] = [{ + kind: 'codicons', + label: localize('chat.agentSessions.backgroundType.codicons.label', "Codicons"), + detail: localize('chat.agentSessions.backgroundType.codicons.detail', "Use a theme-aware pattern of built-in VS Code icons."), +}, { + kind: 'image', + label: localize('chat.agentSessions.backgroundType.image.label', "Image..."), + detail: localize('chat.agentSessions.backgroundType.image.detail', "Choose an image file from this machine."), +}]; interface IChatBackgroundImageLayoutMetadata extends IQuickPickItem { readonly detail: string; @@ -177,12 +199,12 @@ class NewChatInSessionsWindowAction extends Action2 { registerAction2(NewChatInSessionsWindowAction); -class ChangeChatBackgroundAction extends Action2 { +class SetChatBackgroundAction extends Action2 { constructor() { super({ id: CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_COMMAND_ID, - title: localize2('chat.agentSessions.changeBackground', "Change Background..."), + title: localize2('chat.agentSessions.setBackground', "Set Background..."), category: CHAT_CATEGORY, precondition: CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN, menu: [{ @@ -199,9 +221,48 @@ class ChangeChatBackgroundAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const backgroundService = accessor.get(ISessionsChatBackgroundService); - const selected = await accessor.get(IFileDialogService).showOpenDialog({ - title: localize('chat.agentSessions.changeBackground.dialogTitle', "Change Chat Background"), - openLabel: localize('chat.agentSessions.changeBackground.openLabel', "Set Background"), + const quickInputService = accessor.get(IQuickInputService); + const fileDialogService = accessor.get(IFileDialogService); + const backgroundKind = backgroundService.getBackground()?.kind; + const recentImages = backgroundService.getRecentBackgroundImages(); + const recentItems: RecentChatBackgroundTypeItem[] = recentImages.map(image => ({ + kind: 'recentImage', + image, + label: basename(image) || image.fsPath, + detail: image.fsPath, + })); + const items: QuickPickInput[] = [...chatBackgroundTypeItems]; + if (recentItems.length > 0) { + items.push({ + type: 'separator', + label: localize('chat.agentSessions.backgroundType.recentlyUsed', "recently used"), + }, ...recentItems); + } + const currentImage = backgroundService.getConfiguredBackgroundImage(); + const backgroundType = await quickInputService.pick(items, { + title: localize('chat.agentSessions.setBackground.title', "Set Chat Background"), + placeHolder: localize('chat.agentSessions.setBackground.placeholder', "Select a background type"), + activeItem: backgroundKind === 'image' + ? recentItems.find(item => currentImage && isEqual(item.image, currentImage)) + : chatBackgroundTypeItems.find(item => item.kind === backgroundKind), + }); + if (!backgroundType) { + return; + } + if (backgroundType.kind === 'codicons') { + await backgroundService.setBackground(AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET); + status(localize('chat.agentSessions.setBackground.codicons', "Chat background set to Codicons.")); + return; + } + if (backgroundType.kind === 'recentImage') { + await backgroundService.setBackground(backgroundType.image); + status(localize('chat.agentSessions.setBackground.recentImage', "Chat background image set to {0}.", backgroundType.label)); + return; + } + + const selected = await fileDialogService.showOpenDialog({ + title: localize('chat.agentSessions.setBackground.dialogTitle', "Set Chat Background"), + openLabel: localize('chat.agentSessions.setBackground.openLabel', "Set Background"), canSelectFiles: true, canSelectFolders: false, canSelectMany: false, @@ -217,12 +278,12 @@ class ChangeChatBackgroundAction extends Action2 { return; } - await backgroundService.setBackgroundImage(image); - status(localize('chat.agentSessions.changeBackground.changed', "Chat background changed.")); + await backgroundService.setBackground(image); + status(localize('chat.agentSessions.setBackground.image', "Chat background image set.")); } } -registerAction2(ChangeChatBackgroundAction); +registerAction2(SetChatBackgroundAction); class ChangeChatBackgroundLayoutAction extends Action2 { @@ -231,15 +292,15 @@ class ChangeChatBackgroundLayoutAction extends Action2 { id: CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_LAYOUT_COMMAND_ID, title: localize2('chat.agentSessions.changeBackgroundLayout', "Change Background Layout..."), category: CHAT_CATEGORY, - precondition: CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN, + precondition: CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_LAYOUT_WHEN, menu: [{ id: MenuId.CommandPalette, - when: CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN, + when: CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_LAYOUT_WHEN, }, { id: Menus.SessionChatBackgroundContext, group: 'navigation', order: 2, - when: SessionsChatBackgroundAvailableContext, + when: ContextKeyExpr.and(SessionsChatBackgroundAvailableContext, SessionsChatBackgroundImageConfiguredContext), }], }); } @@ -247,17 +308,20 @@ class ChangeChatBackgroundLayoutAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const backgroundService = accessor.get(ISessionsChatBackgroundService); const currentLayout = backgroundService.getBackgroundImageLayout(); - const selected = await accessor.get(IQuickInputService).pick(chatBackgroundImageLayoutItems, { - title: localize('chat.agentSessions.changeBackgroundLayout.title', "Change Chat Background Layout"), - placeHolder: localize('chat.agentSessions.changeBackgroundLayout.placeholder', "Select how the background image is displayed"), - activeItem: chatBackgroundImageLayoutItems.find(item => item.layout === currentLayout), - }); - if (!selected || selected.layout === currentLayout) { - return; + let selected: (typeof chatBackgroundImageLayoutItems)[number] | undefined; + try { + selected = await accessor.get(IQuickInputService).pick(chatBackgroundImageLayoutItems, { + title: localize('chat.agentSessions.changeBackgroundLayout.title', "Change Chat Background Layout"), + placeHolder: localize('chat.agentSessions.changeBackgroundLayout.placeholder', "Select how the background image is displayed"), + activeItem: chatBackgroundImageLayoutItems.find(item => item.layout === currentLayout), + onDidFocus: item => void backgroundService.setBackgroundImageLayout(item.layout, false), + }); + } finally { + await backgroundService.setBackgroundImageLayout(selected?.layout ?? currentLayout, selected !== undefined); + } + if (selected && selected.layout !== currentLayout) { + status(localize('chat.agentSessions.changeBackgroundLayout.changed', "Chat background layout changed to {0}.", selected.label)); } - - await backgroundService.setBackgroundImageLayout(selected.layout); - status(localize('chat.agentSessions.changeBackgroundLayout.changed', "Chat background layout changed to {0}.", selected.label)); } } @@ -268,7 +332,7 @@ class ClearChatBackgroundAction extends Action2 { constructor() { super({ id: CLEAR_AGENT_SESSIONS_CHAT_BACKGROUND_COMMAND_ID, - title: localize2('chat.agentSessions.clearBackground', "Clear Background Image"), + title: localize2('chat.agentSessions.clearBackground', "Clear Background"), category: CHAT_CATEGORY, precondition: CLEAR_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN, menu: [{ @@ -278,14 +342,14 @@ class ClearChatBackgroundAction extends Action2 { id: Menus.SessionChatBackgroundContext, group: 'navigation', order: 3, - when: ContextKeyExpr.and(SessionsChatBackgroundAvailableContext, SessionsChatBackgroundImageConfiguredContext), + when: ContextKeyExpr.and(SessionsChatBackgroundAvailableContext, SessionsChatBackgroundConfiguredContext), }], }); } override async run(accessor: ServicesAccessor): Promise { - await accessor.get(ISessionsChatBackgroundService).clearBackgroundImage(); - status(localize('chat.agentSessions.clearBackground.cleared', "Chat background image cleared.")); + await accessor.get(ISessionsChatBackgroundService).clearBackground(); + status(localize('chat.agentSessions.clearBackground.cleared', "Chat background cleared.")); } } @@ -336,7 +400,8 @@ Registry.as(ConfigurationExtensions.Configuration).regis type: 'string', default: '', scope: ConfigurationScope.MACHINE, - markdownDescription: localize('chat.agentSessions.preferredDarkBackgroundImage', "Specifies an absolute file path or `file` URI for the image displayed behind chat content in the Agents Window when using a dark color theme. The image is hidden in high contrast themes."), + markdownDescription: localize('chat.agentSessions.preferredDarkBackgroundImage', "Specifies `codicons`, an absolute file path, or a `file` URI for the background displayed behind chat content in the Agents Window when using a dark color theme. The background is hidden in high contrast themes."), + examples: ['codicons'], tags: ['experimental'], ignoreSync: true, }, @@ -344,7 +409,8 @@ Registry.as(ConfigurationExtensions.Configuration).regis type: 'string', default: '', scope: ConfigurationScope.MACHINE, - markdownDescription: localize('chat.agentSessions.preferredLightBackgroundImage', "Specifies an absolute file path or `file` URI for the image displayed behind chat content in the Agents Window when using a light color theme. The image is hidden in high contrast themes."), + markdownDescription: localize('chat.agentSessions.preferredLightBackgroundImage', "Specifies `codicons`, an absolute file path, or a `file` URI for the background displayed behind chat content in the Agents Window when using a light color theme. The background is hidden in high contrast themes."), + examples: ['codicons'], tags: ['experimental'], ignoreSync: true, }, diff --git a/src/vs/sessions/contrib/chat/browser/chatBackgroundRenderer.ts b/src/vs/sessions/contrib/chat/browser/chatBackgroundRenderer.ts new file mode 100644 index 00000000000..ef91d321a04 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/chatBackgroundRenderer.ts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { clearNode, DisposableResizeObserver, getWindow } from '../../../../base/browser/dom.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { ISessionsChatBackground } from '../../../services/chatBackground/browser/chatBackgroundService.js'; + +const codiconCellSize = 80; +const codiconDefaults = { width: 960, height: 800 }; +const codiconChoices = [ + Codicon.sparkle, + Codicon.heart, + Codicon.gear, + Codicon.rocket, + Codicon.terminal, + Codicon.code, + Codicon.extensions, + Codicon.lightbulb, + Codicon.beaker, + Codicon.coffee, + Codicon.symbolMethod, + Codicon.symbolClass, + Codicon.debugAlt, + Codicon.gitBranch, + Codicon.book, + Codicon.bell, + Codicon.comment, + Codicon.cloud, + Codicon.database, + Codicon.search, + Codicon.globe, + Codicon.flame, + Codicon.gift, + Codicon.key, + Codicon.paintcan, + Codicon.pin, + Codicon.plug, + Codicon.pulse, + Codicon.radioTower, + Codicon.remote, + Codicon.repo, + Codicon.shield, + Codicon.starFull, + Codicon.tools, + Codicon.wand, + Codicon.zap, +]; + +function hashCodiconCell(row: number, column: number, salt: number): number { + let value = Math.imul(row + 1, 73856093) ^ Math.imul(column + 1, 19349663) ^ Math.imul(salt + 1, 83492791); + value = Math.imul(value ^ (value >>> 13), 1540483477); + return (value ^ (value >>> 15)) >>> 0; +} + +export class SessionsChatBackgroundRenderer extends Disposable { + + private readonly codiconLayer: HTMLElement; + private background: ISessionsChatBackground | undefined; + private codiconGridSize: string | undefined; + + constructor(private readonly element: HTMLElement) { + super(); + + this.codiconLayer = element.ownerDocument.createElement('div'); + this.codiconLayer.className = 'sessions-chat-codicon-background'; + this.codiconLayer.ariaHidden = 'true'; + this.codiconLayer.hidden = true; + this.element.prepend(this.codiconLayer); + this._register(toDisposable(() => this.codiconLayer.remove())); + + const resizeObserver = this._register(new DisposableResizeObserver( + 'SessionsChatBackgroundRenderer', + entries => { + const entry = entries[0]; + if (entry) { + this.renderCodicons(entry.contentRect.width, entry.contentRect.height); + } + }, + getWindow(element) + )); + this._register(resizeObserver.observe(element)); + } + + setBackground(background: ISessionsChatBackground | undefined): void { + this.background = background; + this.element.classList.toggle('has-chat-background', !!background); + this.element.classList.toggle('has-chat-background-image', background?.kind === 'image'); + this.element.style.backgroundImage = background?.kind === 'image' ? background.backgroundImage : ''; + this.element.style.backgroundRepeat = background?.kind === 'image' ? background.backgroundRepeat : ''; + this.element.style.backgroundSize = background?.kind === 'image' ? background.backgroundSize : ''; + this.element.style.backgroundPosition = background?.kind === 'image' ? background.backgroundPosition : ''; + + const showCodicons = background?.kind === 'codicons'; + this.codiconLayer.hidden = !showCodicons; + if (showCodicons) { + this.renderCodicons(this.element.clientWidth, this.element.clientHeight); + } else { + this.codiconGridSize = undefined; + clearNode(this.codiconLayer); + } + } + + private renderCodicons(width: number, height: number): void { + if (this.background?.kind !== 'codicons') { + return; + } + + const columns = Math.max(1, Math.ceil((width || codiconDefaults.width) / codiconCellSize)); + const rows = Math.max(1, Math.ceil((height || codiconDefaults.height) / codiconCellSize)); + const gridSize = `${columns}x${rows}`; + if (gridSize === this.codiconGridSize) { + return; + } + this.codiconGridSize = gridSize; + + const fragment = this.element.ownerDocument.createDocumentFragment(); + for (let row = 0; row < rows; row++) { + for (let column = 0; column < columns; column++) { + if (hashCodiconCell(row, column, 0) % 9 === 0) { + continue; + } + const icon = renderIcon(codiconChoices[hashCodiconCell(row, column, 1) % codiconChoices.length]); + icon.ariaHidden = 'true'; + const horizontalOffset = ((hashCodiconCell(row, column, 2) % 71) - 35) / 100; + const verticalOffset = ((hashCodiconCell(row, column, 3) % 65) - 32) / 100; + const rotation = (hashCodiconCell(row, column, 4) % 71) - 35; + icon.style.left = `${((column + 0.5 + horizontalOffset) / columns) * 100}%`; + icon.style.top = `${((row + 0.5 + verticalOffset) / rows) * 100}%`; + icon.style.transform = `translate(-50%, -50%) rotate(${rotation}deg)`; + icon.style.opacity = `${0.65 + (hashCodiconCell(row, column, 5) % 36) / 100}`; + fragment.append(icon); + } + } + clearNode(this.codiconLayer); + this.codiconLayer.append(fragment); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index f4b675a9ed7..0424e5c7407 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -52,15 +52,8 @@ import { INewChatVoiceTargetService } from './newChatVoice.js'; import { ISessionsChatViewStateService } from './chatViewStateService.js'; import { ExternalSessionBanner } from './externalSessionBanner.js'; import { Menus } from '../../../browser/menus.js'; -import { ISessionsChatBackground, ISessionsChatBackgroundService } from '../../../services/chatBackground/browser/chatBackgroundService.js'; - -export function applySessionsChatBackground(element: HTMLElement, background: ISessionsChatBackground | undefined): void { - element.classList.toggle('has-chat-background-image', !!background); - element.style.backgroundImage = background?.backgroundImage ?? ''; - element.style.backgroundRepeat = background?.backgroundRepeat ?? ''; - element.style.backgroundSize = background?.backgroundSize ?? ''; - element.style.backgroundPosition = background?.backgroundPosition ?? ''; -} +import { ISessionsChatBackgroundService } from '../../../services/chatBackground/browser/chatBackgroundService.js'; +import { SessionsChatBackgroundRenderer } from './chatBackgroundRenderer.js'; export function shouldShowSessionChatTip(sessionStatus: SessionStatus | undefined): boolean { return sessionStatus === undefined || !isActiveSessionStatus(sessionStatus); @@ -89,7 +82,8 @@ export class NewChatView extends AbstractChatView { super(); this.element.classList.add('chat-view-new'); - const updateBackground = () => applySessionsChatBackground(this.element, chatBackgroundService.getBackground()); + const backgroundRenderer = this._register(new SessionsChatBackgroundRenderer(this.element)); + const updateBackground = () => backgroundRenderer.setBackground(chatBackgroundService.getBackground()); this._register(chatBackgroundService.onDidChangeBackground(updateBackground)); updateBackground(); this.kind = isNewChatInSession ? 'newChatInSession' : 'newSession'; @@ -221,7 +215,8 @@ export class ChatView extends AbstractChatView { super(); this.element.classList.add('chat-view-chat'); - const updateBackground = () => applySessionsChatBackground(this.element, this.chatBackgroundService.getBackground()); + const backgroundRenderer = this._register(new SessionsChatBackgroundRenderer(this.element)); + const updateBackground = () => backgroundRenderer.setBackground(this.chatBackgroundService.getBackground()); this._register(this.chatBackgroundService.onDidChangeBackground(updateBackground)); updateBackground(); this._widgetContainer = $('.chat-view-widget'); diff --git a/src/vs/sessions/contrib/chat/browser/media/chatView.css b/src/vs/sessions/contrib/chat/browser/media/chatView.css index 4fbd4ed3c6c..2bbbf358f1b 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatView.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatView.css @@ -18,11 +18,32 @@ min-height: 0; } -.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background-image .interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-list-rows { +.chat-view.has-chat-background { + isolation: isolate; +} + +.chat-view > .sessions-chat-codicon-background { + position: absolute; + inset: 0; + z-index: -1; + overflow: hidden; + pointer-events: none; + color: color-mix(in srgb, var(--vscode-foreground) 14%, transparent); +} + +.chat-view > .sessions-chat-codicon-background[hidden] { + display: none; +} + +.chat-view > .sessions-chat-codicon-background > .codicon { + position: absolute; +} + +.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background .interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-list-rows { background-color: transparent; } -.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background-image .interactive-item-container.interactive-response { +.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background .interactive-item-container.interactive-response { background: color-mix(in srgb, var(--session-view-background) 96%, transparent); border: var(--vscode-strokeThickness) solid color-mix(in srgb, var(--vscode-editorWidget-border, var(--vscode-widget-border)) 70%, transparent); border-radius: var(--vscode-cornerRadius-medium); @@ -31,7 +52,7 @@ overflow: hidden; } -.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background-image .sessions-chat-widget:not(.new-chat-in-session) .new-chat-widget-content { +.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background .sessions-chat-widget:not(.new-chat-in-session) .new-chat-widget-content { box-sizing: border-box; padding: var(--vscode-spacing-size120); background: color-mix(in srgb, var(--session-view-background) 86%, transparent); @@ -42,8 +63,8 @@ box-shadow: 0 var(--vscode-spacing-size80) var(--vscode-spacing-size240) color-mix(in srgb, var(--vscode-widget-shadow) 18%, transparent); } -.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background-image .interactive-session .chat-secondary-toolbar .action-label, -.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background-image .interactive-session .chat-context-usage-widget { +.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background .interactive-session .chat-secondary-toolbar .action-label, +.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background .interactive-session .chat-context-usage-widget { background-color: var(--vscode-chat-list-background, var(--vscode-button-secondaryBackground)); background-image: linear-gradient(var(--vscode-button-secondaryBackground), var(--vscode-button-secondaryBackground)); border: var(--vscode-strokeThickness) solid var(--vscode-button-secondaryBorder); @@ -51,17 +72,17 @@ color: var(--vscode-button-secondaryForeground); } -.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background-image .interactive-session .chat-secondary-toolbar .action-label:hover, -.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background-image .interactive-session .chat-context-usage-widget:hover { +.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background .interactive-session .chat-secondary-toolbar .action-label:hover, +.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background .interactive-session .chat-context-usage-widget:hover { background-image: linear-gradient(var(--vscode-button-secondaryHoverBackground), var(--vscode-button-secondaryHoverBackground)); } -.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background-image .interactive-session .interactive-item-container.interactive-request .value .rendered-markdown { +.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background .interactive-session .interactive-item-container.interactive-request .value .rendered-markdown { background-color: var(--session-view-background); background-image: linear-gradient(var(--vscode-chat-requestBubbleBackground), var(--vscode-chat-requestBubbleBackground)); } -.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background-image .interactive-session .interactive-item-container.interactive-request .value .rendered-markdown.clickable:hover { +.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background .interactive-session .interactive-item-container.interactive-request .value .rendered-markdown.clickable:hover { background-color: var(--session-view-background); background-image: linear-gradient(var(--vscode-chat-requestBubbleHoverBackground), var(--vscode-chat-requestBubbleHoverBackground)); } @@ -86,7 +107,7 @@ } } -.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background-image .interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-tree-sticky-container { +.monaco-workbench.agent-sessions-workbench .part.sessionspart .chat-view.has-chat-background .interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-tree-sticky-container { background-color: transparent; --vscode-chat-list-background: transparent; diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 087ef47c355..fc9cdd8d1a4 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -50,7 +50,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.quickChat', "To start a workspace-less quick chat, use the New Quick Chat command{0} or the plus button on the Chats section in the sessions list. A quick chat has no workspace, so the workspace picker does not apply and the Toggle Side Panel command is disabled.", '')); content.push(localize('sessionsChat.mobileConfig', "On mobile, the mode and model pickers appear as tappable chips below the input. Tap a chip to open a bottom sheet where you can change the selection.")); content.push(localize('sessionsChat.history', "Use up and down arrows to navigate your request history in the input box.")); - content.push(localize('sessionsChat.background', "Outside high contrast themes, use the Change Background command to choose an image behind chat content for the current dark or light color theme. Use Change Background Layout to choose whether the image repeats, stretches, or appears at an edge or corner. Use Clear Background Image to remove the image for the current color theme. These commands are available from the Command Palette and by right-clicking empty chat space. Clear Background Image is shown only when the current color theme has an image. Background customization is unavailable while a high contrast theme is active.")); + content.push(localize('sessionsChat.background', "Outside high contrast themes, use Set Background to choose the built-in theme-aware Codicons pattern, choose a new image, or reuse one of the five most recently selected images. Use Change Background Layout to choose whether an image repeats, stretches, or appears at an edge or corner. Moving through the layout picker previews each option; select one to save it, or press Escape to restore the previous layout. Use Clear Background to remove either background. These commands are available from the Command Palette and by right-clicking empty chat space. Change Background Layout is shown only for images, and Clear Background is shown only when the current color theme has a background. Background customization is unavailable while a high contrast theme is active.")); content.push(localize('sessionsChat.vscodePet', "Use the checked Pet item in the new-session view context menu, or type /vscode-pet, to show or hide the VS Code pet above the input. Drag it horizontally to reposition it, or use Tab to focus it and the left and right arrow keys to move it. Press Enter or Space to show it some love.")); content.push(localize('sessionsChat.vscodePetAchievements', "When the pet is enabled, the user account menu lists unlocked achievement badges before locked badges and provides a View Achievements button. A gold star on the pet announces a newly unlocked achievement; activate the pet while the star is visible to open Achievements.")); content.push(localize('sessionsChat.aquariumAction', "To show or hide the aquarium action on the new-session view, use the checked Aquarium item in the context menu outside the composer, or run the Toggle Aquarium Action Visibility command.")); diff --git a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts index ed387797ce5..c311dbefefa 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts @@ -14,7 +14,8 @@ import { IChatRequestTranscriptContextVariableEntry } from '../../../../../workb import { ChatInputNoticeHost, ChatInputNoticeLane } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputNoticeHost.js'; import { isChatInputStackSlotShowing } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputStack.js'; import { SessionStatus } from '../../../../services/sessions/common/session.js'; -import { applySessionsChatBackground, findTranscriptContextEntry, getTranscriptProgress, NewChatView, shouldShowSessionChatTip, shouldShowTranscriptPreparationProgress } from '../../browser/chatView.js'; +import { findTranscriptContextEntry, getTranscriptProgress, NewChatView, shouldShowSessionChatTip, shouldShowTranscriptPreparationProgress } from '../../browser/chatView.js'; +import { SessionsChatBackgroundRenderer } from '../../browser/chatBackgroundRenderer.js'; import { SessionsChatViewStateService } from '../../browser/chatViewStateService.js'; import { NewChatInSessionWidget } from '../../browser/newChatInSessionWidget.js'; import { NewChatWidget } from '../../browser/newChatWidget.js'; @@ -127,25 +128,29 @@ suite('Sessions - Chat View', () => { const chatView = dom.append(part, dom.$('.chat-view')); dom.getWindow(workbench).document.body.appendChild(workbench); disposables.add(toDisposable(() => workbench.remove())); - applySessionsChatBackground(chatView, { + const renderer = disposables.add(new SessionsChatBackgroundRenderer(chatView)); + renderer.setBackground({ + kind: 'image', backgroundImage: 'url("file:///textures/kirby.png")', backgroundRepeat: 'no-repeat', backgroundSize: 'auto', backgroundPosition: 'right bottom', }); const applied = { - enabled: chatView.classList.contains('has-chat-background-image'), + enabled: chatView.classList.contains('has-chat-background'), + imageEnabled: chatView.classList.contains('has-chat-background-image'), image: chatView.style.backgroundImage, repeat: chatView.style.backgroundRepeat, size: chatView.style.backgroundSize, position: chatView.style.backgroundPosition, }; - applySessionsChatBackground(chatView, undefined); + renderer.setBackground(undefined); assert.deepStrictEqual({ applied, cleared: { - enabled: chatView.classList.contains('has-chat-background-image'), + enabled: chatView.classList.contains('has-chat-background'), + imageEnabled: chatView.classList.contains('has-chat-background-image'), image: chatView.style.backgroundImage, repeat: chatView.style.backgroundRepeat, size: chatView.style.backgroundSize, @@ -154,12 +159,46 @@ suite('Sessions - Chat View', () => { }, { applied: { enabled: true, + imageEnabled: true, image: 'url("file:///textures/kirby.png")', repeat: 'no-repeat', size: 'auto', position: 'right bottom', }, - cleared: { enabled: false, image: '', repeat: '', size: '', position: '' }, + cleared: { enabled: false, imageEnabled: false, image: '', repeat: '', size: '', position: '' }, + }); + }); + + test('renders the codicons background preset from decorative in-memory icons', () => { + const workbench = dom.$('.monaco-workbench.agent-sessions-workbench'); + workbench.style.setProperty('--vscode-foreground', '#202020'); + const part = dom.append(workbench, dom.$('.part.sessionspart')); + const chatView = dom.append(part, dom.$('.chat-view')); + dom.getWindow(workbench).document.body.appendChild(workbench); + disposables.add(toDisposable(() => workbench.remove())); + const renderer = disposables.add(new SessionsChatBackgroundRenderer(chatView)); + renderer.setBackground({ kind: 'codicons' }); + const layer = chatView.querySelector(':scope > .sessions-chat-codicon-background'); + const firstIcon = layer?.querySelector('.codicon'); + + assert.deepStrictEqual({ + enabled: chatView.classList.contains('has-chat-background'), + imageEnabled: chatView.classList.contains('has-chat-background-image'), + backgroundImage: chatView.style.backgroundImage, + layerHidden: layer?.hidden, + layerAriaHidden: layer?.ariaHidden, + layerPointerEvents: layer ? dom.getWindow(layer).getComputedStyle(layer).pointerEvents : undefined, + hasIcons: (layer?.querySelectorAll('.codicon').length ?? 0) > 0, + firstIconAriaHidden: firstIcon?.ariaHidden, + }, { + enabled: true, + imageEnabled: false, + backgroundImage: '', + layerHidden: false, + layerAriaHidden: 'true', + layerPointerEvents: 'none', + hasIcons: true, + firstIconAriaHidden: 'true', }); }); @@ -168,7 +207,7 @@ suite('Sessions - Chat View', () => { workbench.style.setProperty('--session-view-background', '#202020'); workbench.style.setProperty('--vscode-chat-requestBubbleBackground', 'rgba(255, 255, 255, 0.3)'); const part = dom.append(workbench, dom.$('.part.sessionspart')); - const chatView = dom.append(part, dom.$('.chat-view.has-chat-background-image')); + const chatView = dom.append(part, dom.$('.chat-view.has-chat-background')); const session = dom.append(chatView, dom.$('.interactive-session')); const request = dom.append(session, dom.$('.interactive-item-container.interactive-request')); const value = dom.append(request, dom.$('.value')); @@ -202,7 +241,7 @@ suite('Sessions - Chat View', () => { workbench.style.setProperty('--vscode-cornerRadius-medium', '6px'); workbench.style.setProperty('--vscode-spacing-size160', '16px'); const part = dom.append(workbench, dom.$('.part.sessionspart')); - const chatView = dom.append(part, dom.$('.chat-view.has-chat-background-image')); + const chatView = dom.append(part, dom.$('.chat-view.has-chat-background')); const session = dom.append(chatView, dom.$('.interactive-session')); const response = dom.append(session, dom.$('.interactive-item-container.interactive-response')); const value = dom.append(response, dom.$('.value')); @@ -253,7 +292,7 @@ suite('Sessions - Chat View', () => { workbench.style.setProperty('--vscode-spacing-size120', '12px'); workbench.style.setProperty('--vscode-strokeThickness', '1px'); const part = dom.append(workbench, dom.$('.part.sessionspart')); - const chatView = dom.append(part, dom.$('.chat-view.has-chat-background-image')); + const chatView = dom.append(part, dom.$('.chat-view.has-chat-background')); const newChatWidget = dom.append(chatView, dom.$('.sessions-chat-widget')); const newChatContent = dom.append(newChatWidget, dom.$('.new-chat-widget-content')); const inSessionWidget = dom.append(chatView, dom.$('.sessions-chat-widget.new-chat-in-session')); @@ -378,7 +417,7 @@ suite('Sessions - Chat View', () => { const bubble = dom.append(value, dom.$('.rendered-markdown')); return { stickyContainer, stickyRow, treeContents, request, bubble }; }; - const background = createStickyRequest('.chat-view.has-chat-background-image'); + const background = createStickyRequest('.chat-view.has-chat-background'); const plain = createStickyRequest('.chat-view'); dom.getWindow(workbench).document.body.appendChild(workbench); disposables.add(toDisposable(() => workbench.remove())); diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts index 059b16b74a8..efae11d2260 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts @@ -228,7 +228,7 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN override readonly onDidChangeBackground = Event.None; override getBackground() { return undefined; } override getConfiguredBackgroundImage() { return undefined; } - override setBackgroundImage() { return Promise.resolve(); } + override setBackground() { return Promise.resolve(); } }()); }, }); diff --git a/src/vs/sessions/services/chatBackground/browser/chatBackgroundService.ts b/src/vs/sessions/services/chatBackground/browser/chatBackgroundService.ts index e4bd01633ed..48bfab32fdf 100644 --- a/src/vs/sessions/services/chatBackground/browser/chatBackgroundService.ts +++ b/src/vs/sessions/services/chatBackground/browser/chatBackgroundService.ts @@ -8,25 +8,38 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { isAbsolute } from '../../../../base/common/path.js'; +import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ColorScheme, isDark, isHighContrast } from '../../../../platform/theme/common/theme.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; -import { SessionsChatBackgroundAvailableContext, SessionsChatBackgroundImageConfiguredContext } from '../../../common/contextkeys.js'; +import { SessionsChatBackgroundAvailableContext, SessionsChatBackgroundConfiguredContext, SessionsChatBackgroundImageConfiguredContext } from '../../../common/contextkeys.js'; export const AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING = 'chat.agentSessions.preferredDarkBackgroundImage'; export const AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING = 'chat.agentSessions.preferredLightBackgroundImage'; export const AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING = 'chat.agentSessions.backgroundImageLayout'; +export const AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET = 'codicons'; +export type SessionsChatBackgroundPreset = typeof AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET; +const RECENT_BACKGROUND_IMAGES_STORAGE_KEY = 'chat.agentSessions.recentBackgroundImages'; +const MAX_RECENT_BACKGROUND_IMAGES = 5; -export interface ISessionsChatBackground { +export interface ISessionsChatImageBackground { + readonly kind: 'image'; readonly backgroundImage: string; readonly backgroundRepeat: string; readonly backgroundSize: string; readonly backgroundPosition: string; } +export interface ISessionsChatCodiconsBackground { + readonly kind: 'codicons'; +} + +export type ISessionsChatBackground = ISessionsChatImageBackground | ISessionsChatCodiconsBackground; + const backgroundImageStyles = { repeat: { backgroundRepeat: 'repeat', backgroundSize: 'auto', backgroundPosition: 'left top' }, stretch: { backgroundRepeat: 'no-repeat', backgroundSize: '100% 100%', backgroundPosition: 'center center' }, @@ -39,7 +52,7 @@ const backgroundImageStyles = { 'bottom-left': { backgroundRepeat: 'no-repeat', backgroundSize: 'auto', backgroundPosition: 'left bottom' }, left: { backgroundRepeat: 'no-repeat', backgroundSize: 'auto', backgroundPosition: 'left center' }, right: { backgroundRepeat: 'no-repeat', backgroundSize: 'auto', backgroundPosition: 'right center' }, -} as const satisfies Record>; +} as const satisfies Record>; export type ChatBackgroundImageLayout = keyof typeof backgroundImageStyles; @@ -53,10 +66,11 @@ export interface ISessionsChatBackgroundService { readonly onDidChangeBackground: Event; getBackground(): ISessionsChatBackground | undefined; getConfiguredBackgroundImage(): URI | undefined; + getRecentBackgroundImages(): readonly URI[]; getBackgroundImageLayout(): ChatBackgroundImageLayout; - setBackgroundImage(image: URI): Promise; - clearBackgroundImage(): Promise; - setBackgroundImageLayout(layout: ChatBackgroundImageLayout): Promise; + setBackground(background: URI | SessionsChatBackgroundPreset): Promise; + clearBackground(): Promise; + setBackgroundImageLayout(layout: ChatBackgroundImageLayout, persist?: boolean): Promise; } export class SessionsChatBackgroundService extends Disposable implements ISessionsChatBackgroundService { @@ -64,31 +78,43 @@ export class SessionsChatBackgroundService extends Disposable implements ISessio private readonly _onDidChangeBackground = this._register(new Emitter()); readonly onDidChangeBackground = this._onDidChangeBackground.event; + private backgroundImageLayout: ChatBackgroundImageLayout; constructor( @IConfigurationService private readonly configurationService: IConfigurationService, @IThemeService private readonly themeService: IThemeService, @IContextKeyService contextKeyService: IContextKeyService, + @IStorageService private readonly storageService: IStorageService, ) { super(); + this.backgroundImageLayout = this.readConfiguredBackgroundImageLayout(); const backgroundAvailableContext = SessionsChatBackgroundAvailableContext.bindTo(contextKeyService); + const backgroundConfiguredContext = SessionsChatBackgroundConfiguredContext.bindTo(contextKeyService); const backgroundImageConfiguredContext = SessionsChatBackgroundImageConfiguredContext.bindTo(contextKeyService); const updateContextKeys = () => { + const background = this.getConfiguredBackground(); backgroundAvailableContext.set(!isHighContrast(this.themeService.getColorTheme().type)); - backgroundImageConfiguredContext.set(!!this.getConfiguredBackgroundImage()); + backgroundConfiguredContext.set(!!background); + backgroundImageConfiguredContext.set(background?.kind === 'image'); }; updateContextKeys(); this._register(this.configurationService.onDidChangeConfiguration(event => { const backgroundImageChanged = event.affectsConfiguration(AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING) || event.affectsConfiguration(AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING); - if ( - backgroundImageChanged - || event.affectsConfiguration(AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING) - ) { - if (backgroundImageChanged) { - updateContextKeys(); + const backgroundImageLayoutChanged = event.affectsConfiguration(AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING); + let backgroundChanged = backgroundImageChanged; + if (backgroundImageChanged) { + updateContextKeys(); + } + if (backgroundImageLayoutChanged) { + const layout = this.readConfiguredBackgroundImageLayout(); + if (layout !== this.backgroundImageLayout) { + this.backgroundImageLayout = layout; + backgroundChanged = true; } + } + if (backgroundChanged) { this._onDidChangeBackground.fire(); } })); @@ -102,45 +128,121 @@ export class SessionsChatBackgroundService extends Disposable implements ISessio if (isHighContrast(this.themeService.getColorTheme().type)) { return undefined; } - const image = this.getConfiguredBackgroundImage(); - return image ? { - backgroundImage: css.asCSSUrl(image), + const configuredBackground = this.getConfiguredBackground(); + if (configuredBackground?.kind === 'codicons') { + return configuredBackground; + } + return configuredBackground ? { + kind: 'image', + backgroundImage: css.asCSSUrl(configuredBackground.image), ...backgroundImageStyles[this.getBackgroundImageLayout()], } : undefined; } getConfiguredBackgroundImage(): URI | undefined { - const setting = this.getBackgroundImageSetting(this.themeService.getColorTheme().type); - return this.resolveBackgroundImage(this.configurationService.getValue(setting)); + const background = this.getConfiguredBackground(); + return background?.kind === 'image' ? background.image : undefined; } - async setBackgroundImage(image: URI): Promise { - const setting = this.getBackgroundImageSetting(this.themeService.getColorTheme().type); - await this.configurationService.updateValue(setting, image.toString(), ConfigurationTarget.USER); + getRecentBackgroundImages(): readonly URI[] { + const images = this.getStoredRecentBackgroundImages(); + const current = this.getConfiguredBackgroundImage(); + if (current && !images.some(image => isEqual(image, current))) { + images.unshift(current); + } + return images.slice(0, MAX_RECENT_BACKGROUND_IMAGES); } - async clearBackgroundImage(): Promise { + async setBackground(background: URI | SessionsChatBackgroundPreset): Promise { + const setting = this.getBackgroundImageSetting(this.themeService.getColorTheme().type); + await this.configurationService.updateValue(setting, URI.isUri(background) ? background.toString() : background, ConfigurationTarget.USER); + if (URI.isUri(background)) { + this.storeRecentBackgroundImage(background); + } + } + + async clearBackground(): Promise { const setting = this.getBackgroundImageSetting(this.themeService.getColorTheme().type); await this.configurationService.updateValue(setting, undefined, ConfigurationTarget.USER); } getBackgroundImageLayout(): ChatBackgroundImageLayout { + return this.backgroundImageLayout; + } + + async setBackgroundImageLayout(layout: ChatBackgroundImageLayout, persist = true): Promise { + if (layout !== this.backgroundImageLayout) { + this.backgroundImageLayout = layout; + this._onDidChangeBackground.fire(); + } + if (persist) { + try { + await this.configurationService.updateValue(AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, layout, ConfigurationTarget.APPLICATION); + } catch (error) { + const configuredLayout = this.readConfiguredBackgroundImageLayout(); + if (configuredLayout !== this.backgroundImageLayout) { + this.backgroundImageLayout = configuredLayout; + this._onDidChangeBackground.fire(); + } + throw error; + } + } + } + + private readConfiguredBackgroundImageLayout(): ChatBackgroundImageLayout { const value = this.configurationService.getValue(AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING); return chatBackgroundImageLayoutValues.includes(value as ChatBackgroundImageLayout) ? value as ChatBackgroundImageLayout : 'repeat'; } - async setBackgroundImageLayout(layout: ChatBackgroundImageLayout): Promise { - await this.configurationService.updateValue(AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, layout, ConfigurationTarget.APPLICATION); - } - private getBackgroundImageSetting(colorScheme: ColorScheme): string { return isDark(colorScheme) ? AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING : AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING; } + private getConfiguredBackground(): { readonly kind: 'codicons' } | { readonly kind: 'image'; readonly image: URI } | undefined { + const setting = this.getBackgroundImageSetting(this.themeService.getColorTheme().type); + const value = this.configurationService.getValue(setting); + if (value?.trim() === AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET) { + return { kind: 'codicons' }; + } + const image = this.resolveBackgroundImage(value); + return image ? { kind: 'image', image } : undefined; + } + + private getStoredRecentBackgroundImages(): URI[] { + const stored = this.storageService.getObject(RECENT_BACKGROUND_IMAGES_STORAGE_KEY, StorageScope.PROFILE, []); + if (!Array.isArray(stored)) { + return []; + } + const images: URI[] = []; + for (const value of stored) { + if (typeof value !== 'string') { + continue; + } + const image = this.resolveBackgroundImage(value); + if (image && !images.some(existing => isEqual(existing, image))) { + images.push(image); + } + } + return images.slice(0, MAX_RECENT_BACKGROUND_IMAGES); + } + + private storeRecentBackgroundImage(image: URI): void { + const images = [ + image, + ...this.getStoredRecentBackgroundImages().filter(existing => !isEqual(existing, image)), + ].slice(0, MAX_RECENT_BACKGROUND_IMAGES); + this.storageService.store( + RECENT_BACKGROUND_IMAGES_STORAGE_KEY, + JSON.stringify(images.map(recent => recent.toString())), + StorageScope.PROFILE, + StorageTarget.MACHINE + ); + } + private resolveBackgroundImage(value: string | undefined): URI | undefined { const candidate = value?.trim(); if (!candidate) { diff --git a/src/vs/sessions/services/chatBackground/test/browser/chatBackgroundService.test.ts b/src/vs/sessions/services/chatBackground/test/browser/chatBackgroundService.test.ts index 91694ad5556..790c4fd39a7 100644 --- a/src/vs/sessions/services/chatBackground/test/browser/chatBackgroundService.test.ts +++ b/src/vs/sessions/services/chatBackground/test/browser/chatBackgroundService.test.ts @@ -10,13 +10,15 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { ConfigurationTarget, IConfigurationChangeEvent, IConfigurationOverrides, IConfigurationUpdateOptions, IConfigurationUpdateOverrides } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { InMemoryStorageService } from '../../../../../platform/storage/common/storage.js'; import { ColorScheme } from '../../../../../platform/theme/common/theme.js'; import { TestColorTheme, TestThemeService } from '../../../../../platform/theme/test/common/testThemeService.js'; -import { SessionsChatBackgroundAvailableContext, SessionsChatBackgroundImageConfiguredContext } from '../../../../common/contextkeys.js'; -import { AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING, AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING, chatBackgroundImageLayoutValues, ChatBackgroundImageLayout, ISessionsChatBackground, SessionsChatBackgroundService } from '../../browser/chatBackgroundService.js'; +import { SessionsChatBackgroundAvailableContext, SessionsChatBackgroundConfiguredContext, SessionsChatBackgroundImageConfiguredContext } from '../../../../common/contextkeys.js'; +import { AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET, AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING, AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING, chatBackgroundImageLayoutValues, ChatBackgroundImageLayout, ISessionsChatImageBackground, SessionsChatBackgroundService } from '../../browser/chatBackgroundService.js'; class CapturingConfigurationService extends TestConfigurationService { readonly updates: { key: string; value: unknown; target: ConfigurationTarget | undefined }[] = []; + updateError: Error | undefined; override updateValue(key: string, value: unknown): Promise; override updateValue(key: string, value: unknown, target: ConfigurationTarget): Promise; @@ -24,7 +26,7 @@ class CapturingConfigurationService extends TestConfigurationService { override updateValue(key: string, value: unknown, overrides: IConfigurationOverrides | IConfigurationUpdateOverrides, target: ConfigurationTarget, options?: IConfigurationUpdateOptions): Promise; override updateValue(key: string, value: unknown, arg3?: ConfigurationTarget | IConfigurationOverrides | IConfigurationUpdateOverrides, target?: ConfigurationTarget): Promise { this.updates.push({ key, value, target: typeof arg3 === 'number' ? arg3 : target }); - return Promise.resolve(); + return this.updateError ? Promise.reject(this.updateError) : Promise.resolve(); } } @@ -39,16 +41,18 @@ suite('Sessions Chat Background Service', () => { test('does not return a background without a configured image', () => { const contextKeyService = disposables.add(new MockContextKeyService()); - const service = disposables.add(new SessionsChatBackgroundService(new TestConfigurationService(), new TestThemeService(), contextKeyService)); + const service = disposables.add(new SessionsChatBackgroundService(new TestConfigurationService(), new TestThemeService(), contextKeyService, disposables.add(new InMemoryStorageService()))); assert.deepStrictEqual({ background: service.getBackground(), image: service.getConfiguredBackgroundImage(), - configured: contextKeyService.getContextKeyValue(SessionsChatBackgroundImageConfiguredContext.key), + backgroundConfigured: contextKeyService.getContextKeyValue(SessionsChatBackgroundConfiguredContext.key), + imageConfigured: contextKeyService.getContextKeyValue(SessionsChatBackgroundImageConfiguredContext.key), }, { background: undefined, image: undefined, - configured: false, + backgroundConfigured: false, + imageConfigured: false, }); }); @@ -60,36 +64,41 @@ suite('Sessions Chat Background Service', () => { }); const themeService = new TestThemeService(); const contextKeyService = disposables.add(new MockContextKeyService()); - const service = disposables.add(new SessionsChatBackgroundService(configurationService, themeService, contextKeyService)); + const service = disposables.add(new SessionsChatBackgroundService(configurationService, themeService, contextKeyService, disposables.add(new InMemoryStorageService()))); let changes = 0; disposables.add(service.onDidChangeBackground(() => changes++)); const darkBackground = service.getBackground(); const dark = { + kind: darkBackground?.kind, image: service.getConfiguredBackgroundImage()?.path.endsWith('dark.png'), - cssImage: !!darkBackground?.backgroundImage, - repeat: darkBackground?.backgroundRepeat, - size: darkBackground?.backgroundSize, - position: darkBackground?.backgroundPosition, + cssImage: darkBackground?.kind === 'image' && !!darkBackground.backgroundImage, + repeat: darkBackground?.kind === 'image' ? darkBackground.backgroundRepeat : undefined, + size: darkBackground?.kind === 'image' ? darkBackground.backgroundSize : undefined, + position: darkBackground?.kind === 'image' ? darkBackground.backgroundPosition : undefined, available: contextKeyService.getContextKeyValue(SessionsChatBackgroundAvailableContext.key), - configured: contextKeyService.getContextKeyValue(SessionsChatBackgroundImageConfiguredContext.key), + backgroundConfigured: contextKeyService.getContextKeyValue(SessionsChatBackgroundConfiguredContext.key), + imageConfigured: contextKeyService.getContextKeyValue(SessionsChatBackgroundImageConfiguredContext.key), }; themeService.setTheme(new TestColorTheme({}, ColorScheme.LIGHT)); const lightBackground = service.getBackground(); const light = { + kind: lightBackground?.kind, image: service.getConfiguredBackgroundImage()?.path.endsWith('light.png'), - cssImage: !!lightBackground?.backgroundImage, - repeat: lightBackground?.backgroundRepeat, - size: lightBackground?.backgroundSize, - position: lightBackground?.backgroundPosition, + cssImage: lightBackground?.kind === 'image' && !!lightBackground.backgroundImage, + repeat: lightBackground?.kind === 'image' ? lightBackground.backgroundRepeat : undefined, + size: lightBackground?.kind === 'image' ? lightBackground.backgroundSize : undefined, + position: lightBackground?.kind === 'image' ? lightBackground.backgroundPosition : undefined, available: contextKeyService.getContextKeyValue(SessionsChatBackgroundAvailableContext.key), - configured: contextKeyService.getContextKeyValue(SessionsChatBackgroundImageConfiguredContext.key), + backgroundConfigured: contextKeyService.getContextKeyValue(SessionsChatBackgroundConfiguredContext.key), + imageConfigured: contextKeyService.getContextKeyValue(SessionsChatBackgroundImageConfiguredContext.key), }; themeService.setTheme(new TestColorTheme({}, ColorScheme.HIGH_CONTRAST_DARK)); const highContrast = { background: service.getBackground(), available: contextKeyService.getContextKeyValue(SessionsChatBackgroundAvailableContext.key), - configured: contextKeyService.getContextKeyValue(SessionsChatBackgroundImageConfiguredContext.key), + backgroundConfigured: contextKeyService.getContextKeyValue(SessionsChatBackgroundConfiguredContext.key), + imageConfigured: contextKeyService.getContextKeyValue(SessionsChatBackgroundImageConfiguredContext.key), }; themeService.setTheme(new TestColorTheme({}, ColorScheme.DARK)); const restoredAvailability = contextKeyService.getContextKeyValue(SessionsChatBackgroundAvailableContext.key); @@ -101,33 +110,55 @@ suite('Sessions Chat Background Service', () => { light, highContrast, unsupportedUri: service.getBackground(), - unsupportedConfigured: contextKeyService.getContextKeyValue(SessionsChatBackgroundImageConfiguredContext.key), + unsupportedBackgroundConfigured: contextKeyService.getContextKeyValue(SessionsChatBackgroundConfiguredContext.key), + unsupportedImageConfigured: contextKeyService.getContextKeyValue(SessionsChatBackgroundImageConfiguredContext.key), restoredAvailability, changes, }, { - dark: { image: true, cssImage: true, repeat: 'no-repeat', size: 'auto', position: 'center center', available: true, configured: true }, - light: { image: true, cssImage: true, repeat: 'no-repeat', size: 'auto', position: 'center center', available: true, configured: true }, - highContrast: { background: undefined, available: false, configured: true }, + dark: { kind: 'image', image: true, cssImage: true, repeat: 'no-repeat', size: 'auto', position: 'center center', available: true, backgroundConfigured: true, imageConfigured: true }, + light: { kind: 'image', image: true, cssImage: true, repeat: 'no-repeat', size: 'auto', position: 'center center', available: true, backgroundConfigured: true, imageConfigured: true }, + highContrast: { background: undefined, available: false, backgroundConfigured: true, imageConfigured: true }, unsupportedUri: undefined, - unsupportedConfigured: false, + unsupportedBackgroundConfigured: false, + unsupportedImageConfigured: false, restoredAvailability: true, changes: 4, }); }); + test('returns the codicons preset without resolving an image', () => { + const configurationService = new TestConfigurationService({ + [AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING]: AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET, + }); + const contextKeyService = disposables.add(new MockContextKeyService()); + const service = disposables.add(new SessionsChatBackgroundService(configurationService, new TestThemeService(), contextKeyService, disposables.add(new InMemoryStorageService()))); + + assert.deepStrictEqual({ + background: service.getBackground(), + image: service.getConfiguredBackgroundImage(), + backgroundConfigured: contextKeyService.getContextKeyValue(SessionsChatBackgroundConfiguredContext.key), + imageConfigured: contextKeyService.getContextKeyValue(SessionsChatBackgroundImageConfiguredContext.key), + }, { + background: { kind: 'codicons' }, + image: undefined, + backgroundConfigured: true, + imageConfigured: false, + }); + }); + test('returns every configured image layout', async () => { const configurationService = new TestConfigurationService({ [AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING]: URI.file('/textures/kirby.png').fsPath, [AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING]: 'repeat', }); - const service = disposables.add(new SessionsChatBackgroundService(configurationService, new TestThemeService(), disposables.add(new MockContextKeyService()))); - const actual: Partial | undefined>> = {}; + const service = disposables.add(new SessionsChatBackgroundService(configurationService, new TestThemeService(), disposables.add(new MockContextKeyService()), disposables.add(new InMemoryStorageService()))); + const actual: Partial | undefined>> = {}; for (const layout of chatBackgroundImageLayoutValues) { await configurationService.setUserConfiguration(AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, layout); fireConfigurationChange(configurationService, AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING); const background = service.getBackground(); - if (background) { + if (background?.kind === 'image') { actual[layout] = { backgroundRepeat: background.backgroundRepeat, backgroundSize: background.backgroundSize, @@ -151,22 +182,112 @@ suite('Sessions Chat Background Service', () => { }); }); - test('updates the image for the active color theme and the shared layout', async () => { + test('updates the image layout without persisting until the final value is committed', async () => { + const configurationService = new TestConfigurationService({ + [AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING]: URI.file('/textures/kirby.png').fsPath, + [AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING]: 'center', + }); + const service = disposables.add(new SessionsChatBackgroundService(configurationService, new TestThemeService(), disposables.add(new MockContextKeyService()), disposables.add(new InMemoryStorageService()))); + let changes = 0; + disposables.add(service.onDidChangeBackground(() => changes++)); + const getPosition = () => { + const background = service.getBackground(); + return background?.kind === 'image' ? background.backgroundPosition : undefined; + }; + + const configuredPosition = getPosition(); + await service.setBackgroundImageLayout('bottom-right', false); + const previewPosition = getPosition(); + const persistedDuringPreview = configurationService.getValue(AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING); + await service.setBackgroundImageLayout('center', true); + + assert.deepStrictEqual({ + configuredPosition, + previewPosition, + persistedDuringPreview, + restoredPosition: getPosition(), + persistedLayout: configurationService.getValue(AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING), + changes, + }, { + configuredPosition: 'center center', + previewPosition: 'right bottom', + persistedDuringPreview: 'center', + restoredPosition: 'center center', + persistedLayout: 'center', + changes: 2, + }); + }); + + test('restores the configured image layout when persistence fails', async () => { + const configurationService = new CapturingConfigurationService(); + const service = disposables.add(new SessionsChatBackgroundService(configurationService, new TestThemeService(), disposables.add(new MockContextKeyService()), disposables.add(new InMemoryStorageService()))); + let changes = 0; + disposables.add(service.onDidChangeBackground(() => changes++)); + await service.setBackgroundImageLayout('bottom-right', false); + configurationService.updateError = new Error('Unable to save layout'); + + await assert.rejects(service.setBackgroundImageLayout('bottom-right', true), /Unable to save layout/); + + assert.deepStrictEqual({ + layout: service.getBackgroundImageLayout(), + changes, + }, { + layout: 'repeat', + changes: 2, + }); + }); + + test('keeps the five most recently selected background images', async () => { + const initialImage = URI.file('/textures/initial.png'); + const configurationService = new TestConfigurationService({ + [AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING]: initialImage.toString(), + }); + const storageService = disposables.add(new InMemoryStorageService()); + const service = disposables.add(new SessionsChatBackgroundService(configurationService, new TestThemeService(), disposables.add(new MockContextKeyService()), storageService)); + const selectedImages = Array.from({ length: 6 }, (_, index) => URI.file(`/textures/recent-${index + 1}.png`)); + const initialRecents = service.getRecentBackgroundImages(); + for (const image of selectedImages) { + await service.setBackground(image); + } + await service.setBackground(selectedImages[2]); + const restoredService = disposables.add(new SessionsChatBackgroundService(new TestConfigurationService(), new TestThemeService(), disposables.add(new MockContextKeyService()), storageService)); + + assert.deepStrictEqual({ + initialRecents: initialRecents.map(image => image.path), + persistedRecents: restoredService.getRecentBackgroundImages().map(image => image.path), + }, { + initialRecents: ['/textures/initial.png'], + persistedRecents: [ + '/textures/recent-3.png', + '/textures/recent-6.png', + '/textures/recent-5.png', + '/textures/recent-4.png', + '/textures/recent-2.png', + ], + }); + }); + + test('updates the background for the active color theme and the shared layout', async () => { const image = URI.file('/textures/kirby.png'); const configurationService = new CapturingConfigurationService(); const themeService = new TestThemeService(); - const service = disposables.add(new SessionsChatBackgroundService(configurationService, themeService, disposables.add(new MockContextKeyService()))); + const service = disposables.add(new SessionsChatBackgroundService(configurationService, themeService, disposables.add(new MockContextKeyService()), disposables.add(new InMemoryStorageService()))); - await service.setBackgroundImage(image); - await service.clearBackgroundImage(); + await service.setBackground(image); + await service.setBackground(AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET); + await service.clearBackground(); themeService.setTheme(new TestColorTheme({}, ColorScheme.LIGHT)); - await service.setBackgroundImage(image); + await service.setBackground(image); await service.setBackgroundImageLayout('bottom-right'); assert.deepStrictEqual(configurationService.updates, [{ key: AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING, value: image.toString(), target: ConfigurationTarget.USER, + }, { + key: AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING, + value: AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET, + target: ConfigurationTarget.USER, }, { key: AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING, value: undefined, From 3d301f10606230403b4184059c0cbc393e1e220b Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:29:07 -0700 Subject: [PATCH 042/116] pet: add more hats (#332685) * pet: add more hats * fix tests, address comments --- .../test/browser/account.contribution.test.ts | 16 ++ .../contrib/changes/browser/changesView.ts | 25 ++- .../test/browser/changesViewActions.test.ts | 31 +++- .../chat/browser/chatPetAchievements.ts | 4 + .../electron-browser/chat.contribution.ts | 12 -- .../test/browser/chatPetAchievements.test.ts | 12 +- .../agentHost/browser/agentHostModePicker.ts | 21 +++ .../mobile/mobileAgentHostModePicker.ts | 4 +- .../mobile/mobileChatPhoneInputPresenter.ts | 15 +- .../chat/browser/chat.shared.contribution.ts | 3 +- .../chatPetAchievements.contribution.ts | 49 ++++++ .../chat/browser/chatPetAchievements.ts | 150 +++++++++++++++-- .../chatInlineAnchorWidget.ts | 11 +- .../contrib/chat/browser/widget/chatWidget.ts | 1 - .../chatPet/accessories/dark-sailor-hat.png | Bin 0 -> 1003 bytes .../accessories/grand-top-hat-monocle.png | Bin 1143 -> 1050 bytes .../chatPet/accessories/pink-party-hat.png | Bin 0 -> 927 bytes .../chatPet/accessories/propeller-hat.png | Bin 0 -> 896 bytes .../media/chatPet/accessories/rice-hat.png | Bin 0 -> 881 bytes .../media/chatPet/accessories/santa-hat.png | Bin 0 -> 1011 bytes .../media/chatPet/accessories/straw-hat.png | Bin 0 -> 947 bytes .../chatPet/accessories/viking-helmet.png | Bin 1185 -> 0 bytes .../chatPet/accessories/white-chef-hat.png | Bin 0 -> 880 bytes .../media/chatPet/accessories/wizard-hat.png | Bin 0 -> 1016 bytes .../chatPetAchievementsContribution.test.ts | 72 +++++++- .../browser/chatPetAchievementsEditor.test.ts | 63 ++++++- .../chatInlineAnchorWidget.test.ts | 16 +- .../test/browser/widget/chatPetWidget.test.ts | 155 +++++++++++++++++- .../chat/chatPetAccessoryRig.fixture.ts | 44 ++++- .../blocks-ci-screenshots.md | 28 ++-- 30 files changed, 656 insertions(+), 76 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/dark-sailor-hat.png create mode 100644 src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/pink-party-hat.png create mode 100644 src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/propeller-hat.png create mode 100644 src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/rice-hat.png create mode 100644 src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/santa-hat.png create mode 100644 src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/straw-hat.png delete mode 100644 src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/viking-helmet.png create mode 100644 src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/white-chef-hat.png create mode 100644 src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/wizard-hat.png diff --git a/src/vs/sessions/contrib/accountMenu/test/browser/account.contribution.test.ts b/src/vs/sessions/contrib/accountMenu/test/browser/account.contribution.test.ts index 13523e9fa17..31eb9b5a5c4 100644 --- a/src/vs/sessions/contrib/accountMenu/test/browser/account.contribution.test.ts +++ b/src/vs/sessions/contrib/accountMenu/test/browser/account.contribution.test.ts @@ -73,6 +73,14 @@ suite('Sessions - Account Menu', () => { { id: ChatPetAchievementIds.ModelSwitch, unlocked: false }, { id: ChatPetAchievementIds.McpServerPresent, unlocked: false }, { id: ChatPetAchievementIds.CustomSkillPresent, unlocked: false }, + { id: ChatPetAchievementIds.AgentsWindowOpened, unlocked: false }, + { id: ChatPetAchievementIds.CreatePullRequest, unlocked: false }, + { id: ChatPetAchievementIds.AgentEditKept, unlocked: false }, + { id: ChatPetAchievementIds.SessionArchived, unlocked: false }, + { id: ChatPetAchievementIds.AgentChangesReviewed, unlocked: false }, + { id: ChatPetAchievementIds.ChatReferenceOpened, unlocked: false }, + { id: ChatPetAchievementIds.UsefulOutputCopied, unlocked: false }, + { id: ChatPetAchievementIds.AutopilotEnabled, unlocked: false }, ], partial: [ { id: ChatPetAchievementIds.FirstChatMessage, unlocked: true }, @@ -81,6 +89,14 @@ suite('Sessions - Account Menu', () => { { id: ChatPetAchievementIds.ModelSwitch, unlocked: false }, { id: ChatPetAchievementIds.McpServerPresent, unlocked: false }, { id: ChatPetAchievementIds.CustomSkillPresent, unlocked: false }, + { id: ChatPetAchievementIds.AgentsWindowOpened, unlocked: false }, + { id: ChatPetAchievementIds.CreatePullRequest, unlocked: false }, + { id: ChatPetAchievementIds.AgentEditKept, unlocked: false }, + { id: ChatPetAchievementIds.SessionArchived, unlocked: false }, + { id: ChatPetAchievementIds.AgentChangesReviewed, unlocked: false }, + { id: ChatPetAchievementIds.ChatReferenceOpened, unlocked: false }, + { id: ChatPetAchievementIds.UsefulOutputCopied, unlocked: false }, + { id: ChatPetAchievementIds.AutopilotEnabled, unlocked: false }, ], }); }); diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 8be109e9dea..f06c3296f56 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -55,6 +55,8 @@ import { ViewPane, IViewPaneOptions, ViewAction } from '../../../../workbench/br import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; import { IViewDescriptorService } from '../../../../workbench/common/views.js'; import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; +import { ChatPetAchievementIds } from '../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; +import { IChatPetService } from '../../../../workbench/contrib/chat/browser/chatPetService.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { createFileIconThemableTreeContainerScope } from '../../../../workbench/contrib/files/browser/views/explorerView.js'; import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; @@ -103,6 +105,14 @@ const singlePaneChangesEditorHeader = ContextKeyExpr.and( ActiveEditorContext.isEqualTo(SessionChangesEditorInput.EDITOR_ID) ); const EMPTY_FILE_CHANGES_MIN_HEIGHT = 140; +const CHAT_PET_CREATE_PULL_REQUEST_ACTION_IDS = new Set([ + 'create-pr', + 'create-pr-auto-merge', + 'create-pr-auto-squash', + 'create-pr-auto-rebase', + 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR', + 'workbench.action.agentSessions.runSkill.createPR', +]); /** Breathing room rendered beneath the last file row when the whole list fits. */ const TREE_PANE_LIST_BOTTOM_PADDING = 12; @@ -110,6 +120,11 @@ const TREE_PANE_LIST_BOTTOM_PADDING = 12; /** The file changes section always reserves room for at least this many file rows. */ const TREE_PANE_MIN_VISIBLE_ROWS = 5; +export function unlockChatPetCreatePullRequestAchievement(actionId: string, chatPetService: IChatPetService): boolean { + return CHAT_PET_CREATE_PULL_REQUEST_ACTION_IDS.has(actionId) + && chatPetService.unlockAchievement(ChatPetAchievementIds.CreatePullRequest); +} + // --- ButtonBar widget /** @@ -140,7 +155,8 @@ class ChangesMenuWorkbenchButtonBarWidget extends Disposable implements IChanges @IContextMenuService contextMenuService: IContextMenuService, @IKeybindingService keybindingService: IKeybindingService, @ITelemetryService telemetryService: ITelemetryService, - @IHoverService hoverService: IHoverService + @IHoverService hoverService: IHoverService, + @IChatPetService chatPetService: IChatPetService, ) { super(); @@ -190,7 +206,10 @@ class ChangesMenuWorkbenchButtonBarWidget extends Disposable implements IChanges ); // Set the running label override - reader.store.add(buttonBar.onWillRun(e => runningLabelObs.set(e.action.label, undefined))); + reader.store.add(buttonBar.onWillRun(e => { + runningLabelObs.set(e.action.label, undefined); + unlockChatPetCreatePullRequestAchievement(e.action.id, chatPetService); + })); this._currentButtonBar = buttonBar; reader.store.add(buttonBar.onDidChange(() => this._onDidChangeActions.fire())); @@ -283,6 +302,7 @@ class ChangesWorkbenchButtonBarWidget extends Disposable implements IChangesButt @IChangesViewService changesViewService: IChangesViewService, @IContextKeyService contextKeyService: IContextKeyService, @IInstantiationService instantiationService: IInstantiationService, + @IChatPetService chatPetService: IChatPetService, ) { super(); @@ -301,6 +321,7 @@ class ChangesWorkbenchButtonBarWidget extends Disposable implements IChangesButt } } )); + this._register(buttonBar.onWillRun(e => unlockChatPetCreatePullRequestAchievement(e.action.id, chatPetService))); this.onDidChangeActions = Event.signal(buttonBar.onDidChange); const menuActionsObs = observableFromEvent(menu.onDidChange, () => { diff --git a/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts b/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts index a08b6217b84..d2b881ebfe0 100644 --- a/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts @@ -18,6 +18,8 @@ import { TestInstantiationService } from '../../../../../platform/instantiation/ import { EditorContextKeys } from '../../../../../editor/common/editorContextKeys.js'; import { SessionsDiffRenderSideBySideContext } from '../../../editor/common/diffEditorOptionsService.js'; import { ActiveEditorContext, AuxiliaryBarVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext, MainEditorAreaVisibleContext, TextCompareEditorActiveContext } from '../../../../../workbench/common/contextkeys.js'; +import { ChatPetAchievementId, ChatPetAchievementIds } from '../../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; +import { IChatPetService } from '../../../../../workbench/contrib/chat/browser/chatPetService.js'; import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; import { Menus } from '../../../../browser/menus.js'; import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; @@ -26,7 +28,7 @@ import { IActiveSession } from '../../../../services/sessions/common/sessionsMan import { ChangesContextKeys, ChangesViewMode } from '../../common/changes.js'; import { IsPhoneLayoutContext, SessionHasChangesContext, SessionHasWorkspaceContext, SessionIsCreatedContext, SinglePaneDiffEditorInputActiveContext, SinglePaneLayoutEnabledContext } from '../../../../common/contextkeys.js'; import { SessionChangesEditor } from '../../browser/sessionChangesEditor.js'; -import { CHANGES_HEADER_ACTIONS_ID } from '../../browser/changesView.js'; +import { CHANGES_HEADER_ACTIONS_ID, unlockChatPetCreatePullRequestAchievement } from '../../browser/changesView.js'; import { SessionsChangesAccessibilityHelp } from '../../browser/sessionsChangesAccessibilityHelp.js'; import '../../browser/changesViewActions.js'; @@ -79,6 +81,33 @@ suite('Changes View Actions', () => { }]); }); + test('Create PR button actions unlock Ship it without drafts or updates', () => { + const attemptedUnlocks: ChatPetAchievementId[] = []; + const chatPetService = new class extends mock() { + override unlockAchievement(id: ChatPetAchievementId): boolean { + attemptedUnlocks.push(id); + return true; + } + }(); + + const results = [ + 'create-pr', + 'create-pr-auto-merge', + 'create-pr-auto-squash', + 'create-pr-auto-rebase', + 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR', + 'workbench.action.agentSessions.runSkill.createPR', + 'create-draft-pr', + 'workbench.action.agentSessions.runSkill.createDraftPR', + 'workbench.action.agentSessions.runSkill.updatePR', + ].map(actionId => unlockChatPetCreatePullRequestAchievement(actionId, chatPetService)); + + assert.deepStrictEqual({ results, attemptedUnlocks }, { + results: [true, true, true, true, true, true, false, false, false], + attemptedUnlocks: Array(6).fill(ChatPetAchievementIds.CreatePullRequest), + }); + }); + test('primary header actions gate themselves to the single-pane Changes editor', () => { const items = MenuRegistry.getMenuItems(Menus.SessionsEditorHeaderPrimary) .filter(isIMenuItem) diff --git a/src/vs/sessions/contrib/chat/browser/chatPetAchievements.ts b/src/vs/sessions/contrib/chat/browser/chatPetAchievements.ts index 535be796c4b..af8300ca294 100644 --- a/src/vs/sessions/contrib/chat/browser/chatPetAchievements.ts +++ b/src/vs/sessions/contrib/chat/browser/chatPetAchievements.ts @@ -18,11 +18,15 @@ export class SessionsChatPetAchievementContribution extends Disposable implement @IChatPetService chatPetService: IChatPetService, ) { super(); + chatPetService.unlockAchievement(ChatPetAchievementIds.AgentsWindowOpened); this._register(sessionsManagementService.onDidSendRequest(event => { chatPetService.unlockAchievement(ChatPetAchievementIds.FirstChatMessage); if (hasChatPetImageAttachment(event.options.attachedContext ?? [])) { chatPetService.unlockAchievement(ChatPetAchievementIds.ImageRequest); } })); + this._register(sessionsManagementService.onDidArchiveSession(() => { + chatPetService.unlockAchievement(ChatPetAchievementIds.SessionArchived); + })); } } diff --git a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts index b50922b7c95..533bbeee63b 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts @@ -27,8 +27,6 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet import { TOTAL_SESSIONS_KEY } from '../../sessions/browser/sessionsLifecycleTracker.js'; import { ISessionsWindowOpenViewState, SessionsWindowOpenTelemetry, SessionsWindowSessionStartTelemetry } from '../../sessions/browser/sessionsWindowOpenTelemetry.js'; import { INewSessionComposerService, NewSessionWorkspacePreselectionSource } from '../browser/newSessionComposerService.js'; -import { ChatPetAchievementIds } from '../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; -import { IChatPetService } from '../../../../workbench/contrib/chat/browser/chatPetService.js'; class SelectAgentsFolderContribution extends Disposable implements IWorkbenchContribution { @@ -226,18 +224,8 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon } } -class ChatPetAgentsWindowAchievementContribution implements IWorkbenchContribution { - - static readonly ID = 'sessions.contrib.chatPetAgentsWindowAchievement'; - - constructor(@IChatPetService chatPetService: IChatPetService) { - chatPetService.unlockAchievement(ChatPetAchievementIds.AgentsWindowOpened); - } -} - registerWorkbenchContribution2(SelectAgentsFolderContribution.ID, SelectAgentsFolderContribution, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(SessionsCopilotConfigSlashSubmitHandlerContribution.ID, SessionsCopilotConfigSlashSubmitHandlerContribution, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(ChatPetAgentsWindowAchievementContribution.ID, ChatPetAgentsWindowAchievementContribution, WorkbenchPhase.AfterRestored); // Renderer-side BYOK language-model handler that backs the node agent host's // OpenAI proxy, mirroring the registration in the workbench's diff --git a/src/vs/sessions/contrib/chat/test/browser/chatPetAchievements.test.ts b/src/vs/sessions/contrib/chat/test/browser/chatPetAchievements.test.ts index f642141253a..34dbe88fbf7 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatPetAchievements.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatPetAchievements.test.ts @@ -10,16 +10,19 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { ChatPetAchievementId, ChatPetAchievementIds } from '../../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; import { IChatPetService } from '../../../../../workbench/contrib/chat/browser/chatPetService.js'; import { ISendRequestSentEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISession } from '../../../../services/sessions/common/session.js'; import { SessionsChatPetAchievementContribution } from '../../browser/chatPetAchievements.js'; suite('Sessions - Chat Pet Achievements', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('unlocks the first message and observes paused image sends', () => { + test('unlocks Agents window, request, and archive achievements from shared contribution', () => { const onDidSendRequest = disposables.add(new Emitter()); + const onDidArchiveSession = disposables.add(new Emitter()); const attemptedUnlocks: ChatPetAchievementId[] = []; const sessionsManagementService = new class extends mock() { override readonly onDidSendRequest = onDidSendRequest.event; + override readonly onDidArchiveSession = onDidArchiveSession.event; }(); const chatPetService = new class extends mock() { override unlockAchievement(id: ChatPetAchievementId): boolean { @@ -36,13 +39,18 @@ suite('Sessions - Chat Pet Achievements', () => { isNewChat: true, options: { query: 'hello', - attachedContext: [{ kind: 'image', id: 'image', name: 'image', value: '' }], + attachedContext: [ + { kind: 'image', id: 'image', name: 'image', value: '' }, + ], }, }); + onDidArchiveSession.fire(undefined!); assert.deepStrictEqual(attemptedUnlocks, [ + ChatPetAchievementIds.AgentsWindowOpened, ChatPetAchievementIds.FirstChatMessage, ChatPetAchievementIds.ImageRequest, + ChatPetAchievementIds.SessionArchived, ]); }); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts index faf34004e6e..8a2b09fd85f 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts @@ -21,6 +21,8 @@ import { ISessionsProvidersService } from '../../../../services/sessions/browser import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { type ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { reportNewChatPickerClosed } from '../../../chat/browser/newChatPickerTelemetry.js'; +import { ChatPetAchievementIds, didExplicitlyEnableChatPetAutopilot } from '../../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; +import { IChatPetService } from '../../../../../workbench/contrib/chat/browser/chatPetService.js'; import { getAgentHostModeIcon } from './agentHostModeIcon.js'; import { isWellKnownModeSchema } from './agentHostPermissionPickerDelegate.js'; @@ -120,6 +122,7 @@ export abstract class AgentHostSessionEnumPicker extends Disposable { protected abstract _getWidgetAriaLabel(): string; protected _getFooterActionItems(): readonly IActionListItem[] { return []; } protected _handleFooterActionItem(_item: IAgentHostSessionEnumPickerItem): boolean { return false; } + protected _onDidSelectValue(_previousValue: string, _selectedValue: string): void { } /** * Optional list-widget options for the picker popup. Subclasses whose @@ -259,6 +262,7 @@ export abstract class AgentHostSessionEnumPicker extends Disposable { isPII: false, }); ctx.provider.setSessionConfigValue(ctx.sessionId, this._property, item.value) + .then(() => this._onDidSelectValue(ctx.currentValue, item.value)) .catch(() => { /* best-effort */ }); }, onHide: () => { @@ -295,6 +299,23 @@ export class AgentHostModePicker extends AgentHostSessionEnumPicker { protected readonly _pickerId = 'agentHostModePicker'; protected readonly _telemetryId = 'NewChatAgentHostModePicker'; + constructor( + session: IObservable, + @IActionWidgetService actionWidgetService: IActionWidgetService, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + @ITelemetryService telemetryService: ITelemetryService, + @IHoverService hoverService: IHoverService, + @IChatPetService protected readonly _chatPetService: IChatPetService, + ) { + super(session, actionWidgetService, sessionsProvidersService, telemetryService, hoverService); + } + + protected override _onDidSelectValue(previousValue: string, selectedValue: string): void { + if (didExplicitlyEnableChatPetAutopilot(previousValue, selectedValue)) { + this._chatPetService.unlockAchievement(ChatPetAchievementIds.AutopilotEnabled); + } + } + protected override _getListOptions(): IActionListOptions { return { minWidth: 260 }; } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileAgentHostModePicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileAgentHostModePicker.ts index cbe1156467b..a1948993588 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileAgentHostModePicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileAgentHostModePicker.ts @@ -35,9 +35,9 @@ export class MobileAgentHostModePicker extends AgentHostModePicker { @IHoverService hoverService: IHoverService, @IChatPhoneInputPresenter private readonly _phonePresenter: IChatPhoneInputPresenter, @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, - @IChatPetService private readonly _chatPetService: IChatPetService, + @IChatPetService protected override readonly _chatPetService: IChatPetService, ) { - super(session, actionWidgetService, sessionsProvidersService, telemetryService, hoverService); + super(session, actionWidgetService, sessionsProvidersService, telemetryService, hoverService, _chatPetService); } protected override _showPicker(anchor = this._triggerElement, onHide?: () => void): boolean { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatPhoneInputPresenter.ts b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatPhoneInputPresenter.ts index 10a25b7aa7c..ba8bc9bd8f4 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatPhoneInputPresenter.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatPhoneInputPresenter.ts @@ -15,6 +15,8 @@ import { IInstantiationService } from '../../../../../../platform/instantiation/ import { IUriIdentityService } from '../../../../../../platform/uriIdentity/common/uriIdentity.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../../workbench/common/contributions.js'; import { IToggleChatModeArgs, ToggleAgentModeActionId } from '../../../../../../workbench/contrib/chat/browser/actions/chatExecuteActions.js'; +import { ChatPetAchievementIds, didExplicitlyEnableChatPetAutopilot } from '../../../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; +import { IChatPetService } from '../../../../../../workbench/contrib/chat/browser/chatPetService.js'; import { ChatPhoneInputPresenterRequest, IChatPhoneInputPresenter, IChatPhoneInputSessionContext, IChatPhonePresenterImpl } from '../../../../../../workbench/contrib/chat/browser/widget/input/chatPhoneInputPresenter.js'; import { IModePickerDelegate } from '../../../../../../workbench/contrib/chat/browser/widget/input/modePickerActionItem.js'; import { IModelPickerDelegate } from '../../../../../../workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.js'; @@ -66,6 +68,7 @@ class MobileChatPhoneInputPresenter extends Disposable implements IChatPhonePres @ISessionsService private readonly _sessionsService: ISessionsService, @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, + @IChatPetService private readonly _chatPetService: IChatPetService, ) { super(); @@ -242,9 +245,17 @@ class MobileChatPhoneInputPresenter extends Disposable implements IChatPhonePres break; case 'agentHostMode': if (session && agentHostProvider) { - const schema = agentHostProvider.getSessionConfig(session.sessionId)?.schema.properties[SessionConfigKey.Mode]; + const config = agentHostProvider.getSessionConfig(session.sessionId); + const schema = config?.schema.properties[SessionConfigKey.Mode]; if (schema && isWellKnownModeValue(schema, action.value)) { - agentHostProvider.setSessionConfigValue(session.sessionId, SessionConfigKey.Mode, action.value).catch(() => { }); + const previousMode = String(config?.values[SessionConfigKey.Mode] ?? schema.default ?? ''); + agentHostProvider.setSessionConfigValue(session.sessionId, SessionConfigKey.Mode, action.value) + .then(() => { + if (didExplicitlyEnableChatPetAutopilot(previousMode, action.value)) { + this._chatPetService.unlockAchievement(ChatPetAchievementIds.AutopilotEnabled); + } + }) + .catch(() => { }); } } break; diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index c98c7bb2349..279687b25a3 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -198,7 +198,7 @@ import { ChatVariablesService } from './attachments/chatVariables.js'; import { ChatImageCarouselService, IChatImageCarouselService } from './chatImageCarouselService.js'; import { ChatOutputRendererService, IChatOutputRendererService } from './chatOutputItemRenderer.js'; import { ChatCompatibilityNotifier, ChatExtensionPointHandler } from './chatParticipant.contribution.js'; -import { ChatPetAchievementsAccessibilityHelp, ChatPetContextContribution, ChatPetCustomizationAchievementContribution } from './chatPetAchievements.contribution.js'; +import { ChatPetAchievementsAccessibilityHelp, ChatPetContextContribution, ChatPetCustomizationAchievementContribution, ChatPetEditingAchievementContribution } from './chatPetAchievements.contribution.js'; import { ChatPetService, IChatPetService } from './chatPetService.js'; import { ChatPetWidgetService, IChatPetWidgetService } from './widget/chatPetWidgetService.js'; import { ChatPromoNotificationContribution } from './chatPromoNotification.js'; @@ -3093,6 +3093,7 @@ registerWorkbenchContribution2(ChatReferenceAttachmentWidgetContribution.ID, Cha registerWorkbenchContribution2(TranscriptContextAttachmentWidgetContribution.ID, TranscriptContextAttachmentWidgetContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatPetContextContribution.ID, ChatPetContextContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatPetCustomizationAchievementContribution.ID, ChatPetCustomizationAchievementContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(ChatPetEditingAchievementContribution.ID, ChatPetEditingAchievementContribution, WorkbenchPhase.AfterRestored); registerChatActions(); registerChatAccessibilityActions(); diff --git a/src/vs/workbench/contrib/chat/browser/chatPetAchievements.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatPetAchievements.contribution.ts index 0b26c0601b6..a2ea0a09482 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPetAchievements.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPetAchievements.contribution.ts @@ -13,6 +13,7 @@ import { Categories } from '../../../../platform/action/common/actionCommonCateg import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType } from '../../../../platform/accessibility/browser/accessibleView.js'; import { IAccessibleViewImplementation } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; @@ -132,6 +133,54 @@ export class ChatPetContextContribution extends Disposable implements IWorkbench } } +const CHAT_PET_KEEP_EDIT_COMMAND_IDS = new Set([ + 'chatEditing.acceptFile', + 'chatEditing.acceptAllFiles', + 'chatEditor.action.accept', + 'chatEditor.action.acceptHunk', + 'chatEditor.action.acceptAllEdits', + 'chatEditing.multidiff.acceptAllFiles', +]); + +const CHAT_PET_REVIEW_EDIT_COMMAND_IDS = new Set([ + 'chatEditor.action.reviewChanges', + 'chatEditing.openFileInDiff', + 'chatEditing.viewChanges', + 'chatEditing.viewAllSessionChanges', + 'workbench.changesView.action.viewChanges', +]); + +const CHAT_PET_COPY_OUTPUT_COMMAND_IDS = new Set([ + 'workbench.action.chat.copyAll', + 'workbench.action.chat.copyItem', + 'workbench.action.chat.copyFinalResponse', + 'workbench.action.chat.copyCodeBlock', +]); + +export class ChatPetEditingAchievementContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.chatPetEditingAchievement'; + + constructor( + @ICommandService commandService: ICommandService, + @IChatPetService chatPetService: IChatPetService, + ) { + super(); + + this._register(commandService.onDidExecuteCommand(event => { + if (CHAT_PET_KEEP_EDIT_COMMAND_IDS.has(event.commandId)) { + chatPetService.unlockAchievement(ChatPetAchievementIds.AgentEditKept); + } + if (CHAT_PET_REVIEW_EDIT_COMMAND_IDS.has(event.commandId)) { + chatPetService.unlockAchievement(ChatPetAchievementIds.AgentChangesReviewed); + } + if (CHAT_PET_COPY_OUTPUT_COMMAND_IDS.has(event.commandId)) { + chatPetService.unlockAchievement(ChatPetAchievementIds.UsefulOutputCopied); + } + })); + } +} + export class ChatPetCustomizationAchievementContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.chatPetCustomizationAchievements'; diff --git a/src/vs/workbench/contrib/chat/browser/chatPetAchievements.ts b/src/vs/workbench/contrib/chat/browser/chatPetAchievements.ts index 8ebf39f472c..e6adea3050f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPetAchievements.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPetAchievements.ts @@ -14,6 +14,13 @@ export const ChatPetAchievementIds = { ModelSwitch: 'modelSwitch', QueueOrSteeringMessage: 'queueOrSteeringMessage', AgentsWindowOpened: 'agentsWindowOpened', + CreatePullRequest: 'createPullRequest', + AgentEditKept: 'agentEditKept', + SessionArchived: 'sessionArchived', + AgentChangesReviewed: 'agentChangesReviewed', + ChatReferenceOpened: 'chatReferenceOpened', + UsefulOutputCopied: 'usefulOutputCopied', + AutopilotEnabled: 'autopilotEnabled', IntegratedBrowserShared: 'integratedBrowserShared', ChatOutputCopied: 'chatOutputCopied', CustomSkillPresent: 'customSkillPresent', @@ -28,14 +35,21 @@ export const ChatPetAccessoryIds = { CowboyHat: 'cowboyHat', TopHatMonocle: 'topHatMonocle', SailorHat: 'sailorHat', + DarkSailorHat: 'darkSailorHat', BaseballCap: 'baseballCap', PartyHat: 'partyHat', + PinkPartyHat: 'pinkPartyHat', SpinnerHat: 'spinnerHat', + PropellerHat: 'propellerHat', ConstructionHardHat: 'constructionHardHat', FirefighterHelmet: 'firefighterHelmet', - VikingHelmet: 'vikingHelmet', Crown: 'crown', ArtistBeret: 'artistBeret', + RiceHat: 'riceHat', + SantaHat: 'santaHat', + StrawHat: 'strawHat', + WhiteChefHat: 'whiteChefHat', + WizardHat: 'wizardHat', } as const; export type ChatPetAccessoryId = typeof ChatPetAccessoryIds[keyof typeof ChatPetAccessoryIds]; @@ -54,7 +68,7 @@ export interface IChatPetAchievement { readonly title: string; readonly description: string; readonly hint: string; - readonly accessories: readonly [IChatPetAccessory, ...IChatPetAccessory[]]; + readonly accessories: readonly [IChatPetAccessory]; readonly enabled: boolean; } @@ -160,6 +174,118 @@ const enabledChatPetAchievements: readonly IChatPetAchievement[] = [ }, ], }, + { + id: ChatPetAchievementIds.AgentsWindowOpened, + title: localize('chatPet.achievement.agentsWindowOpened.title', "Mission Control"), + description: localize('chatPet.achievement.agentsWindowOpened.description', "You opened the Agents window."), + hint: localize('chatPet.achievement.agentsWindowOpened.hint', "Some agent work belongs in its own window."), + enabled: true, + accessories: [{ + id: ChatPetAccessoryIds.PropellerHat, + label: localize('chatPet.accessory.propellerHat', "Propeller Hat"), + atlasName: 'propeller-hat', + atlasCellSize: 96, + coversAntennae: true, + }], + }, + { + id: ChatPetAchievementIds.CreatePullRequest, + title: localize('chatPet.achievement.createPullRequest.title', "Ship it"), + description: localize('chatPet.achievement.createPullRequest.description', "You used Create PR in the Agents window."), + hint: localize('chatPet.achievement.createPullRequest.hint', "When the changes are ready, send them on their way."), + enabled: true, + accessories: [{ + id: ChatPetAccessoryIds.DarkSailorHat, + label: localize('chatPet.accessory.darkSailorHat', "Dark Sailor Hat"), + atlasName: 'dark-sailor-hat', + atlasCellSize: 96, + coversAntennae: true, + }], + }, + { + id: ChatPetAchievementIds.AgentEditKept, + title: localize('chatPet.achievement.agentEditKept.title', "Let it cook"), + description: localize('chatPet.achievement.agentEditKept.description', "You kept a change prepared by Chat."), + hint: localize('chatPet.achievement.agentEditKept.hint', "Give a good idea time to come together."), + enabled: true, + accessories: [{ + id: ChatPetAccessoryIds.WhiteChefHat, + label: localize('chatPet.accessory.whiteChefHat', "White Chef Hat"), + atlasName: 'white-chef-hat', + atlasCellSize: 96, + coversAntennae: true, + }], + }, + { + id: ChatPetAchievementIds.SessionArchived, + title: localize('chatPet.achievement.sessionArchived.title', "Wrapped Up"), + description: localize('chatPet.achievement.sessionArchived.description', "You archived an agent session."), + hint: localize('chatPet.achievement.sessionArchived.hint', "Finished work deserves a tidy ending."), + enabled: true, + accessories: [{ + id: ChatPetAccessoryIds.SantaHat, + label: localize('chatPet.accessory.santaHat', "Santa Hat"), + atlasName: 'santa-hat', + atlasCellSize: 96, + coversAntennae: true, + }], + }, + { + id: ChatPetAchievementIds.AgentChangesReviewed, + title: localize('chatPet.achievement.agentChangesReviewed.title', "Trust but Verify"), + description: localize('chatPet.achievement.agentChangesReviewed.description', "You opened agent changes for review."), + hint: localize('chatPet.achievement.agentChangesReviewed.hint', "Take a closer look before keeping the changes."), + enabled: true, + accessories: [{ + id: ChatPetAccessoryIds.RiceHat, + label: localize('chatPet.accessory.riceHat', "Rice Hat"), + atlasName: 'rice-hat', + atlasCellSize: 96, + coversAntennae: true, + }], + }, + { + id: ChatPetAchievementIds.ChatReferenceOpened, + title: localize('chatPet.achievement.chatReferenceOpened.title', "Follow the Trail"), + description: localize('chatPet.achievement.chatReferenceOpened.description', "You opened a file or code reference from Chat."), + hint: localize('chatPet.achievement.chatReferenceOpened.hint', "Useful answers often point somewhere worth exploring."), + enabled: true, + accessories: [{ + id: ChatPetAccessoryIds.StrawHat, + label: localize('chatPet.accessory.strawHat', "Straw Hat"), + atlasName: 'straw-hat', + atlasCellSize: 96, + coversAntennae: true, + }], + }, + { + id: ChatPetAchievementIds.UsefulOutputCopied, + title: localize('chatPet.achievement.usefulOutputCopied.title', "Copy That"), + description: localize('chatPet.achievement.usefulOutputCopied.description', "You copied useful output from Chat."), + hint: localize('chatPet.achievement.usefulOutputCopied.hint', "Keep something useful from a chat response."), + enabled: true, + accessories: [{ + id: ChatPetAccessoryIds.PinkPartyHat, + label: localize('chatPet.accessory.pinkPartyHat', "Pink Party Hat"), + atlasName: 'pink-party-hat', + atlasCellSize: 96, + coversAntennae: true, + }], + }, + { + id: ChatPetAchievementIds.AutopilotEnabled, + title: localize('chatPet.achievement.autopilotEnabled.title', "Party Mode"), + description: localize('chatPet.achievement.autopilotEnabled.description', "You switched an agent session from Interactive to Autopilot."), + hint: localize('chatPet.achievement.autopilotEnabled.hint', "Some work is ready to carry on with less steering."), + enabled: true, + accessories: [{ + id: ChatPetAccessoryIds.WizardHat, + label: localize('chatPet.accessory.wizardHat', "Wizard Hat"), + atlasName: 'wizard-hat', + atlasCellSize: 96, + coversAntennae: true, + }], + }, ]; export const disabledChatPetAchievements: readonly IChatPetAchievement[] = [ @@ -171,7 +297,7 @@ export const disabledChatPetAchievements: readonly IChatPetAchievement[] = [ enabled: false, accessories: [{ id: ChatPetAccessoryIds.SailorHat, - label: localize('chatPet.accessory.sailorHat', "Sailor Hat"), + label: localize('chatPet.accessory.sailorHat', "Light Sailor Hat"), atlasName: 'sailor-hat', atlasCellSize: 96, coversAntennae: true, @@ -191,20 +317,6 @@ export const disabledChatPetAchievements: readonly IChatPetAchievement[] = [ coversAntennae: true, }], }, - { - id: ChatPetAchievementIds.AgentsWindowOpened, - title: localize('chatPet.achievement.agentsWindowOpened.title', "Mission Control"), - description: localize('chatPet.achievement.agentsWindowOpened.description', "You opened the Agents window."), - hint: localize('chatPet.achievement.agentsWindowOpened.hint', "Some agent work belongs in its own window."), - enabled: false, - accessories: [{ - id: ChatPetAccessoryIds.VikingHelmet, - label: localize('chatPet.accessory.vikingHelmet', "Viking Helmet"), - atlasName: 'viking-helmet', - atlasCellSize: 96, - coversAntennae: true, - }], - }, { id: ChatPetAchievementIds.ChatOutputCopied, title: localize('chatPet.achievement.chatOutputCopied.title', "Copy That"), @@ -285,6 +397,10 @@ export function didExplicitlySwitchChatPetModel(previousModelIdentifier: string return previousModelIdentifier !== undefined && previousModelIdentifier !== selectedModelIdentifier; } +export function didExplicitlyEnableChatPetAutopilot(previousMode: string, selectedMode: string): boolean { + return previousMode === 'interactive' && selectedMode === 'autopilot'; +} + export function hasChatPetImageAttachment(entries: readonly { readonly kind: string }[]): boolean { return entries.some(entry => entry.kind === 'image'); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts index 73fdd530396..241311b16fd 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts @@ -44,6 +44,8 @@ import { IWorkspaceSymbol } from '../../../../search/common/search.js'; import { IChatContentInlineReference } from '../../../common/chatService/chatService.js'; import { IChatWidgetService } from '../../chat.js'; import { IChatImageCarouselService } from '../../chatImageCarouselService.js'; +import { ChatPetAchievementIds } from '../../chatPetAchievements.js'; +import { IChatPetService } from '../../chatPetService.js'; import { chatAttachmentResourceContextKey, hookUpSymbolAttachmentDragAndContextMenu } from '../../attachments/chatAttachmentWidgets.js'; import { IChatMarkdownAnchorService } from './chatMarkdownAnchorService.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -153,6 +155,7 @@ export class InlineAnchorWidget extends Disposable { @INotebookDocumentService private readonly notebookDocumentService: INotebookDocumentService, @IOpenerService private readonly openerService: IOpenerService, @IEditorService private readonly editorService: IEditorService, + @IChatPetService private readonly chatPetService: IChatPetService, ) { super(); @@ -308,8 +311,10 @@ export class InlineAnchorWidget extends Disposable { selection: location.range, }; + let opened = false; const open = async () => { if (this.options?.openResource && await this.options.openResource(location.uri, editorOptions)) { + opened = true; return; } @@ -317,10 +322,11 @@ export class InlineAnchorWidget extends Disposable { const mimeType = getMediaMime(location.uri.path); if (mimeType?.startsWith('image/') && this.configurationService.getValue(ChatConfiguration.ImageCarouselEnabled)) { await this.chatImageCarouselService.openCarouselAtResource(location.uri); + opened = true; return; } - await this.openerService.open(location.uri, { + opened = await this.openerService.open(location.uri, { fromUserGesture: true, editorOptions }); @@ -331,6 +337,9 @@ export class InlineAnchorWidget extends Disposable { } else { await open(); } + if (opened) { + this.chatPetService.unlockAchievement(ChatPetAchievementIds.ChatReferenceOpened); + } })); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 55cacdbf536..c8e5824923f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -3376,7 +3376,6 @@ export class ChatWidget extends Disposable implements IChatWidget { if (submittedWithImage) { this.chatPetService.unlockAchievement(ChatPetAchievementIds.ImageRequest); } - if (!options.preserveInput) { // Not a user submission; listeners would consume draft state. Also skips editor pinning. this._onDidSubmitAgent.fire({ agent: sent.data.agent, slashCommand: sent.data.slashCommand }); diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/dark-sailor-hat.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/dark-sailor-hat.png new file mode 100644 index 0000000000000000000000000000000000000000..39b2fd2cf62a1152ee80f5781cc3e97621f76a55 GIT binary patch literal 1003 zcmeAS@N?(olHy`uVBq!ia0y~yU~B+l1r9c#$llva0vQ;XH+i}^hE&{odv~KAbD_+E zkM&3IT$;djYg59+43{l}NuL?qR!{%H(xW!nQ;F@Ph|;+SjBCu4^#lAL+GVIx zLT>a3GH@_3FflYRFeorUmA(2rSnWUSr=9t`|GfVG zMe7(C1Q-}tfC0(i0A)4=to_ZjU~yW{=<7|96tK$sklYvmn0V$|pTviTBHeOr3r zsq??h{}(-v-(!;=yFAWX>Z|zq#rxu~eXg(B@mc#NHWMa)wPQH%tpC08#k2R7U$9w% zE50|al4J}B-TvMG@SNZ4uRou(E&lboWwQG(p0L`#_Fd3f>4T6!goPl`#R;#@NHH3$ zyrq45shoYO?EkdoQ@8v*{(QPNT3`dUaNJ{OFwk7LE`QN`ZczB%K5ssIY2E+5Z+jpNPv>O0yuIH)-o_8U$M84d3NXj>r}zNH3b*@>+Zckll9E=(-c!Ysq3b+?1uG@w}%di zH=Xgja)15b37qp=?D^--5&{}Mh(Li>(QW=2KIywN>{hrveBGTTac=#uzXktx-EI4P zb{FUK(^V7sfAVgt|84*Dukh~_Rt6@91_lNN1_llWe9Q~W=7~B~=XEbHh?{5M{`un{ zojQj~@$>QT9{+r$B=h`M^$QkEb7~BBzGeS2qyC=j=TF{$)O+^7tLFbzee1Q{m+flu zpQLmC|DFkSsNQ^gpiB3xXnR<*IWMaAZ}haUj~4u|pToc)z`(!)j6MbjD6`?ocZLTG zY}VC1?VJDJ_W&od97LJN`=7Q9_uKEi{l4(_{`ZU7)zM44A<%dcMF-?T>j zCd{`;A=2mf@9*bbSF5*8H|Mv@7T8e#dwc!wawd3q!CiB2;q-5N+;4xgy%(JR|6Bh5 zZ|||h7B~SYpu6j(Mm_(5@44lt|GmBcuMmsb|0V^W{$8S=FSp5b`MGzVxA*_OZ4dJu zl4%oa_!M?7pM7WYF`z*(zaT|0u4o}qyy5?2{uht;h)kO4_6(%e)78&qol`;+04S;$ Ay#N3J literal 1143 zcmeAS@N?(olHy`uVBq!ia0y~yU~B+l1r9c#$llva0vQ-s>^xl@Ln`LHy=#~yk|@#e zF!H3}r4F$q#psUKxn*UX6)e`p4Ati!@Y=L3i1pfN;JSfB^N5q6Vg;*h z_$qmt6J0OVq^uvc47cCS%rE->@9@p@ud81?+yCbCzxDU--TNSJ=kL6C&i}gCn=`-t z|Gcl^?=uz#0aXTuKqhp`^p9PG@A>ZZ$Y=ZK`c{ANkH0_r?&ABU-~RB=7G`jm!oc9d ziB4U~`t$ih`R!kCp8LJ`oW0++^hdlOHlsoIt@f>cQgGsqyTeSJraiL0z%Mud&DUDC z>*Dg?s{HT#X{;|~V(1WJXwYCpqmbkLM&Z1zd@^euTfg30Cx7g0|7zFz#dkS*uzR!A z^oz{y>AiE?5F+gPyKhg2uHwnMCtBbUR_hQ@2lw#^Uw09L9Tmi z!H6aHK?dvYQ_l7;*MX|5#1;FbP0l+XkK3)N8D diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/pink-party-hat.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/pink-party-hat.png new file mode 100644 index 0000000000000000000000000000000000000000..7be63797d21d84a7f429064e796d01aa44d78cbf GIT binary patch literal 927 zcmeAS@N?(olHy`uVBq!ia0y~yU~B+l1r9c#$llva0vQ;X(>z@qLn>~)z3b@Z6e!a0 z@UxBvhm?hnfae{Dki$YP1&nNGW7`*-ug~4WlzD?~4!h%)hGo1<8g&;~{>i=Pm)4%M-|qiA?5`WmpuoVu!N9=8 z(7*s?{=ZmvdH#c%vdQcC*X3)=CBBN?_P;k&uJ^L#|Ni#6U*GlH-;w<{7V_ zHF@2v;(KM^{^zI7_kSt#=gn8m`i4z8-?(eOD!ybv_zvdLBh&uMHN1De|K|I`+x7R| z`Tfv>0T>*(zz6kKU4|cbAMM+E>e~5p-#_u+SiSy`eiNetnh^{Suu*a_;}8 zyZ-<8Rp0Qt?*G2+$l)SVUn*ZZGeKE#O#R9N_Od}nC5 z(QEnt~)z3b@36ew}v zWB#N@Go>A$*fw*yMeh{2o#OsNE_V?#i>~M);VnY>7gjNEdB>*u_SRJH<;)`M6S$WD z`MXX2(Fd1*HkJI!KvM@1Xo#I?`j>J2vd>zk@2CHL^4hvz->h9-H{KdEK zt6#s`b?x^1`=fWMC$J+M0Wq!RKQqVk1^@Y8@4x$1)_2n5-=*f3@;$dt{=9v&u;`vv z_Y<4>vDkGU>ATBZz-Kkb`Tz0P|4+ZJ4#>%}jl1{yM;+W6Bo{xFbFg5)JE!=pg8<;<Y%Q~loCICRt&0PQh literal 0 HcmV?d00001 diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/rice-hat.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/rice-hat.png new file mode 100644 index 0000000000000000000000000000000000000000..a3772ef395f396f678672bcbef0b64a759122e82 GIT binary patch literal 881 zcmeAS@N?(olHy`uVBq!ia0y~yU~B+l1r9c#$llva0vQ;XEj(QuLn>~)y=$1o#Caii1PXaPj`MYoxT6(VSjx$1_cHN4h9A$h6V;G^P!9b&%X0N z-){B&_4{j5_WUhB@4u}{KfCnv&&$tdefc%Ha_%?gjoEXRH!v|cFfa%(FaUka2xUIn zd5-Z0yX?1x{~!N+KJR_Sl<$AP&%ONOZ{3~z1HkZxDTC?gaJObpkT1Ded*Ru9v;X^k z=7!~7J3qgA-_rf^&pFnh8_vSe@Z)na!_xKY^P^vVeN`NP{@$O*E8jn72t)S*6T<=X zsjnBhUwpl=eouYd&)ssi5sa8V#SujghEoL@{=6$$lONmvp8xeb`5RAn7P%WB`xEQ| zg*nU&9g5#Ro}cCY{PXg&rZ2y$f3|wdUUfYTi-R02*cn=C{|Wy6{pa}NJ@5C&4>PG3}#}#gvw5O&bpye_VSb1bfdf5 zTM}c-zkkoZ{`HIX4tr+fYE4F<;e!Y`PkddM@a^$+n_Y3W|Ag-CHneNWA3U(w6SH-HUW z+b_jT&+fmeG2edd;or$8&n>WbICiz_598#`#VlbA3>*v$ObiVS3<^-DL)GnKhLfK! zY<_<6d-=b1clC#V#vi>>4gTUwYlv|Ic0i4G*8son8NFPvr(C zxa;9s{+;xFZ~T7uuG%l9zptF#fA?pzD4NAUH(W66Ub;Ww_vPb`?>GPZ`TP8yv)9f4 zmp$jl;>-imr$KJBd)5Bi`h0NygrCgV6xDAyE|YQg>)G52zGsher0)Iy^GRy|frEt= zJEJ0b*RSt&bNzq+(!aQh+J{-Q)?x|CCo&8M0-Jm9R-XJ@`kcXQ>OAF$x~el+Y;q{# zWAM29!>-}%+QN$eDVvO6{On!*^#`-v^Bh)a=pu#k-pv^-4$=GSe@n&fx3Mn`JBcN1 d83q?J~)y=&;D94K+% z<9C%TMIk2^-9uFj6Gd*GQ?B4W9dbLWnvt`D;rxcB;sG|Ti#AS}k$1CUv-yvg_KhM{x^*_LeFac(m$K79Y4v(|m8LzLFt9n~?M*a=DT`UX@ zPpTOvM18RNzih95R@{b&w)XbVnHPWm|2wyU}R8WVBlb2U}9)sfHLE? z&FkiWzMFaRlYI4W*VpZjColTCH-39>-}8sh{Ehy9J!|hj?OpJ3EN;4?QhE2hvplce zV_})~UH9rOJY3H^uaBNDRVTOW=lb%03G4`4U>4$v(6!Th8D6Y<^=k)Le8IQCZ;Xpq z|6r=IIQvgo0@Zl?)_gNAYUIpW$pUS#d7hF-!d>vvS(yO)riX%10-IP b{-0sn-SUmQb3}p3f`P%))z4*}Q$iB}_!{tk literal 0 HcmV?d00001 diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/viking-helmet.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/viking-helmet.png deleted file mode 100644 index 44943a8b3e5bed82c5778271e09ee70dee7faedd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1185 zcmeAS@N?(olHy`uVBq!ia0y~yU~B+l1r9c#$llva0vQ-sGCW-zLn`LHy=&+v8Yt3` zIIoaVr}2`Mlyg${o0tae1&-kf2e~F36WzgJ?VRnDvtU~aXFmHbru*8*PM(?j|96yA z#hjL(#gmKM@BaJw@BB%Y<>~q5>WRV6&8PmH{eO~;{jqOJFZme`7_l%Ycrh?6LZ$lN zJ-GJ0=J`Eido%gZ=KnwanK@mU!C?vmg9|4*b>)fdor~X>xZf`}`+xXM{q4``QtAv1 z8jK7bLg>_r>Dl%3f8EmE`#k60v-o-Tmn|5v7znh5RIqw$^e<+H*x%28+XwIa|K&~Y zFUf85E%uB5f4`W)KnSZ-zVPX8$opE&^t*Hae?vb8hCn6;0aXmj`TYr|+{O2WZ=B!r zy#CGiVkRuU0(rG+=NEQ{8QJG+-`W0N>bv*&0~Tzi0j+rYpYcGCUF|;UD*nIUp4~D| zIy?s>@S#!h=EtG7??0wLlRLP}5=S_>oZYwB`j_0-7u9E;)!(>$y?fzv<6WOuA7A)? zpWAa>PS_em@b*2mT!2x7=`h@^_gx54YpA>g~3Fc0ZT;-VHYY zpLs{Pp&n;g+>))1x5%0K{bBGkTuw0k!}n!&xjf&!Zv*HXOpqdRAY}he=F55^=*)&$Ke*2ra X)ujVF0xqSdgRJs&^>bP0l+XkKnKoEr diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/white-chef-hat.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/accessories/white-chef-hat.png new file mode 100644 index 0000000000000000000000000000000000000000..2bb5d8d5453f821ab03e6245d06149f550694c2f GIT binary patch literal 880 zcmeAS@N?(olHy`uVBq!ia0y~yU~B+l1r9c#$llva0vQ;X%{^TlLn>~)y=$0tC_v`G z!|9I%qpZ3ndr$P!dnP8iIZYt;&YV4+s~2zI@LWK!^^)F4rf&?b&nHAJe*gR02c>`0 z<@w_#2>^{7M4%xg#W#OL%-Zvs{2R2o-%Q(||L@uSPk%C>o#bF(0s5c8fq?<&WH3{~ z=20=j^mCTae`>sBocGGAs@CE~`gNQ1ynoOBpZI*74^25x=Z~eyECtrP--S3+5CUd#w z8n4J+zWib6)SUlytCLdd+x`gdc`nWfv|tc{H(iTf^1nMjt@IqPU!VWxdk;Q5GN}Cf zr!1hv@<7$j>2v%I>&{ixKg<8O+ot~3*Fzi(EWlu5aA05%V1O_aghhQ{&sn~@ut@g( z*^9B)tX>xF_lnzJ^HXbAjQig&0w>R#@0qjg{(+kP(IcN21{`|VsyY;uP|D0WatHw6-`@hw7|Cwj@ij_X#MDqnu zdxzaz_6OfDEWUi-*}Q(gJO4a{A7L(P*e|m4^_kgE);ZrVn_pkg^ZEU`64Pz|4E_uZ z91ILh3=Iqn3Q(rQn@g1p@9Xy5d|7&B^F<}g`9G^Jy{cq*cK_X2`Pbyfm)HE}*XNsj{d_+A+<*LQr&pi+Df0OFA-@Dj_`~AC0ci1W1_rYu zz(^>XTeCA>H69YYA3$*s9@}0Eb(sRfu>#*18IDX_^SFQ7dEpngt|n*7LtTdCo1FQL zf()~>_RZU}dwS;8_vh`Ub+gd@+kou+LBZaz^&dm@#~D*B9y;@Z9OUWh=d#Wzp$PzN CF%l;L literal 0 HcmV?d00001 diff --git a/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsContribution.test.ts index 98f113c0ff0..2769fdf47bd 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsContribution.test.ts @@ -10,8 +10,9 @@ import { constObservable, observableValue } from '../../../../../base/common/obs import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ICommandEvent, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; -import { ChatPetCustomizationAchievementContribution } from '../../browser/chatPetAchievements.contribution.js'; +import { ChatPetCustomizationAchievementContribution, ChatPetEditingAchievementContribution } from '../../browser/chatPetAchievements.contribution.js'; import { IAICustomizationItemSource, IAICustomizationListItem } from '../../browser/aiCustomization/aiCustomizationItemSource.js'; import { IAICustomizationItemsModel, ItemsModelSection } from '../../browser/aiCustomization/aiCustomizationItemsModel.js'; import { ChatPetAchievementId, ChatPetAchievementIds } from '../../browser/chatPetAchievements.js'; @@ -21,7 +22,7 @@ import { ICustomizationHarnessService } from '../../common/customizationHarnessS import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IMcpWorkbenchService, IWorkbenchMcpServer } from '../../../mcp/common/mcpTypes.js'; -suite('Chat Pet Customization Achievements', () => { +suite('Chat Pet Achievement Contributions', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); function customization(id: string, section: PromptsType): IAICustomizationListItem { @@ -152,4 +153,71 @@ suite('Chat Pet Customization Achievements', () => { ], }); }); + + test('unlocks Let it cook only for explicit keep-edit commands', () => { + const onDidExecuteCommand = disposables.add(new Emitter()); + const attemptedUnlocks: ChatPetAchievementId[] = []; + const commandService = new class extends mock() { + override readonly onDidExecuteCommand = onDidExecuteCommand.event; + }(); + const chatPetService = new class extends mock() { + override unlockAchievement(id: ChatPetAchievementId): boolean { + attemptedUnlocks.push(id); + return true; + } + }(); + disposables.add(new ChatPetEditingAchievementContribution(commandService, chatPetService)); + + for (const commandId of [ + 'chatEditing.acceptFile', + 'chatEditing.acceptAllFiles', + 'chatEditor.action.accept', + 'chatEditor.action.acceptHunk', + 'chatEditor.action.acceptAllEdits', + 'chatEditing.multidiff.acceptAllFiles', + '_chat.editSessions.accept', + 'chatEditing.discardFile', + 'chatEditor.action.reject', + ]) { + onDidExecuteCommand.fire({ commandId, args: [] }); + } + + assert.deepStrictEqual(attemptedUnlocks, Array(6).fill(ChatPetAchievementIds.AgentEditKept)); + }); + + test('unlocks review and copy achievements only for their explicit commands', () => { + const onDidExecuteCommand = disposables.add(new Emitter()); + const attemptedUnlocks: ChatPetAchievementId[] = []; + const commandService = new class extends mock() { + override readonly onDidExecuteCommand = onDidExecuteCommand.event; + }(); + const chatPetService = new class extends mock() { + override unlockAchievement(id: ChatPetAchievementId): boolean { + attemptedUnlocks.push(id); + return true; + } + }(); + disposables.add(new ChatPetEditingAchievementContribution(commandService, chatPetService)); + + for (const commandId of [ + 'chatEditor.action.reviewChanges', + 'chatEditing.openFileInDiff', + 'chatEditing.viewChanges', + 'chatEditing.viewAllSessionChanges', + 'workbench.changesView.action.viewChanges', + 'workbench.action.chat.copyAll', + 'workbench.action.chat.copyItem', + 'workbench.action.chat.copyFinalResponse', + 'workbench.action.chat.copyCodeBlock', + 'workbench.action.chat.copyKatexMathSource', + 'chatEditing.discardAllFiles', + ]) { + onDidExecuteCommand.fire({ commandId, args: [] }); + } + + assert.deepStrictEqual(attemptedUnlocks, [ + ...Array(5).fill(ChatPetAchievementIds.AgentChangesReviewed), + ...Array(4).fill(ChatPetAchievementIds.UsefulOutputCopied), + ]); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsEditor.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsEditor.test.ts index 160de1e6084..80b119c808e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsEditor.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsEditor.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { mainWindow } from '../../../../../base/browser/window.js'; import { toDisposable } from '../../../../../base/common/lifecycle.js'; -import { constObservable } from '../../../../../base/common/observable.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; @@ -143,6 +143,67 @@ suite('Chat Pet Achievements Editor', () => { }); }); + test('renders each unlocked hat as its own achievement card', () => { + const parent = mainWindow.document.createElement('div'); + mainWindow.document.body.appendChild(parent); + store.add(toDisposable(() => parent.remove())); + const selectedAccessory = observableValue(store, undefined); + let selected: ChatPetAccessoryId | undefined; + const chatPetService = new class extends mock() { + override readonly enabled = constObservable(true); + override readonly unlockedAchievements = constObservable([ + ChatPetAchievementIds.FirstChatMessage, + ChatPetAchievementIds.SessionArchived, + ]); + override readonly unseenAchievements = constObservable([]); + override readonly selectedAccessory = selectedAccessory; + override readonly variant = constObservable('stable'); + + override markAchievementSeen(): boolean { + return false; + } + + override setAccessory(accessory: ChatPetAccessoryId | undefined): void { + selected = accessory; + selectedAccessory.set(accessory, undefined); + } + }(); + store.add(new ChatPetAchievementsWidget( + parent, + () => { }, + chatPetService, + new TestThemeService(), + store.add(new NullLogService()), + )); + + const unlockedCards = Array.from(parent.querySelectorAll('.chat-pet-achievement-card.monaco-button:not(.locked)')); + const santaCard = parent.querySelector(`[data-accessory-id="${ChatPetAccessoryIds.SantaHat}"]`); + assert.ok(santaCard); + santaCard.click(); + + assert.deepStrictEqual({ + unlockedCardIds: unlockedCards.map(card => card.dataset.accessoryId), + firstMessageTitleCount: Array.from(parent.querySelectorAll('h3')).filter(title => title.textContent === 'Welcome to the Wild West').length, + wrappedUpTitleCount: Array.from(parent.querySelectorAll('h3')).filter(title => title.textContent === 'Wrapped Up').length, + selected, + santaSelected: santaCard.getAttribute('aria-pressed'), + santaAriaLabel: santaCard.getAttribute('aria-label'), + santaState: santaCard.querySelector('.chat-pet-achievement-state')?.textContent, + }, { + unlockedCardIds: [ + 'none', + ChatPetAccessoryIds.CowboyHat, + ChatPetAccessoryIds.SantaHat, + ], + firstMessageTitleCount: 1, + wrappedUpTitleCount: 1, + selected: ChatPetAccessoryIds.SantaHat, + santaSelected: 'true', + santaAriaLabel: 'Wrapped Up. Reward: Santa Hat. Wearing', + santaState: 'Wearing', + }); + }); + test('requests modal close when Escape is pressed on a selectable card', () => { const parent = mainWindow.document.createElement('div'); mainWindow.document.body.appendChild(parent); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatInlineAnchorWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatInlineAnchorWidget.test.ts index 3ed6fbc3a86..5841bfd47ef 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatInlineAnchorWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatInlineAnchorWidget.test.ts @@ -4,8 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { DeferredPromise } from '../../../../../../../base/common/async.js'; +import { DeferredPromise, timeout } from '../../../../../../../base/common/async.js'; import { URI } from '../../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { renderFileWidgets } from '../../../../browser/widget/chatContentParts/chatInlineAnchorWidget.js'; import { mainWindow } from '../../../../../../../base/browser/window.js'; @@ -15,6 +16,8 @@ import { IChatMarkdownAnchorService } from '../../../../browser/widget/chatConte import { MarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { ChatQueryTitlePart } from '../../../../browser/widget/chatContentParts/chatConfirmationWidget.js'; import { getChatMarkdownRenderOptions } from '../../../../browser/widget/chatContentMarkdownRenderer.js'; +import { ChatPetAchievementId, ChatPetAchievementIds } from '../../../../browser/chatPetAchievements.js'; +import { IChatPetService } from '../../../../browser/chatPetService.js'; suite('ChatInlineAnchorWidget Metadata Validation', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -22,6 +25,7 @@ suite('ChatInlineAnchorWidget Metadata Validation', () => { let disposables: DisposableStore; let instantiationService: ReturnType; let mockAnchorService: IChatMarkdownAnchorService; + let attemptedUnlocks: ChatPetAchievementId[]; setup(() => { disposables = store.add(new DisposableStore()); @@ -35,6 +39,13 @@ suite('ChatInlineAnchorWidget Metadata Validation', () => { }; instantiationService.stub(IChatMarkdownAnchorService, mockAnchorService); + attemptedUnlocks = []; + instantiationService.stub(IChatPetService, new class extends mock() { + override unlockAchievement(id: ChatPetAchievementId): boolean { + attemptedUnlocks.push(id); + return true; + } + }()); }); function createTestElement(linkText: string, href: string = 'file:///test.txt'): HTMLElement { @@ -76,6 +87,8 @@ suite('ChatInlineAnchorWidget Metadata Validation', () => { element.querySelector('.chat-inline-anchor-widget')?.click(); assert.strictEqual((await opened.p).toString(), resource.toString()); + await timeout(0); + assert.deepStrictEqual(attemptedUnlocks, [ChatPetAchievementIds.ChatReferenceOpened]); }); test('wraps the resource opener in trackOpen', async () => { @@ -122,6 +135,7 @@ suite('ChatInlineAnchorWidget Metadata Validation', () => { element.querySelector('.chat-inline-anchor-widget')?.click(); assert.strictEqual(await failure.p, error); + assert.deepStrictEqual(attemptedUnlocks, []); }); test('renders widget for empty vscode-agent-host link in chat query title', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts index 55d2e1d395f..cf6fb2f4cec 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts @@ -19,7 +19,7 @@ import { StorageScope, StorageTarget } from '../../../../../../platform/storage/ import { NullTelemetryServiceShape } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; import { TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; import { IHostService } from '../../../../../services/host/browser/host.js'; -import { CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID, chatPetAchievements, ChatPetAccessoryIds, ChatPetAchievementIds, disabledChatPetAchievements, getChatPetAchievement, getChatPetAchievementPresentation, getChatPetCustomizationAchievementIds, getUnlockedChatPetAccessories, isUserAuthoredChatPetCustomization, shouldUnlockChatPetIntegratedBrowserShare } from '../../../browser/chatPetAchievements.js'; +import { CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID, chatPetAchievements, ChatPetAccessoryIds, ChatPetAchievementIds, didExplicitlyEnableChatPetAutopilot, disabledChatPetAchievements, getChatPetAchievement, getChatPetAchievementPresentation, getChatPetCustomizationAchievementIds, getUnlockedChatPetAccessories, isUserAuthoredChatPetCustomization, shouldUnlockChatPetIntegratedBrowserShare } from '../../../browser/chatPetAchievements.js'; import { ChatPetService, getChatPetVariant } from '../../../browser/chatPetService.js'; import { getChatPetAccessoryImageSource, hasChatPetAccessoryImageDimensions, hasChatPetBodyImageDimensions } from '../../../browser/widget/chatPetAccessoryRenderer.js'; import { getChatPetAccessoryRigFrame, getChatPetAccessoryRigPose, getChatPetAccessoryTrack, getChatPetAntennaeOcclusionBounds, getChatPetEyeAccessoryAnchor, getChatPetReducedMotionRigFrame } from '../../../browser/widget/chatPetAccessoryRig.js'; @@ -884,7 +884,7 @@ suite('ChatPetWidget', () => { service.setHorizontalPosition(0.3); storageService.store('chat.vscodePet.achievement.chatFork', true, StorageScope.APPLICATION_SHARED, StorageTarget.USER); storageService.store('chat.vscodePet.achievement.chatFork', true, StorageScope.APPLICATION, StorageTarget.USER); - const disabledUnlock = service.unlockAchievement(ChatPetAchievementIds.InstructionPresent); + const disabledUnlock = service.unlockAchievement(ChatPetAchievementIds.QueueOrSteeringMessage); service.resetAchievements(); storageService.store('chat.vscodePet.achievementCatalogVersion', 3, StorageScope.APPLICATION_SHARED, StorageTarget.USER); const migratedService = disposables.add(new ChatPetService(storageService, new TestTelemetryService(), new NullLogService())); @@ -1058,6 +1058,15 @@ suite('ChatPetWidget', () => { ], [false, false, false, true]); }); + test('recognizes only an explicit Interactive to Autopilot switch', () => { + assert.deepStrictEqual([ + didExplicitlyEnableChatPetAutopilot('interactive', 'plan'), + didExplicitlyEnableChatPetAutopilot('plan', 'autopilot'), + didExplicitlyEnableChatPetAutopilot('interactive', 'autopilot'), + didExplicitlyEnableChatPetAutopilot('autopilot', 'autopilot'), + ], [false, false, true, false]); + }); + test('finds customization achievements from user-authored items and MCP servers', () => { assert.deepStrictEqual([ getChatPetCustomizationAchievementIds([], [], 0), @@ -1074,11 +1083,13 @@ suite('ChatPetWidget', () => { ]); }); - test('defines one unique covered-antennae reward for each achievement', () => { + test('defines unique covered-antennae rewards for each achievement', () => { + const accessoryIds = chatPetAchievements.flatMap(achievement => achievement.accessories.map(accessory => accessory.id)); assert.deepStrictEqual({ count: chatPetAchievements.length, achievementIds: chatPetAchievements.map(achievement => achievement.id), - accessoryIds: chatPetAchievements.flatMap(achievement => achievement.accessories.map(accessory => accessory.id)), + accessoryIds, + uniqueAccessoryCount: new Set(accessoryIds).size, atlasNames: chatPetAchievements.flatMap(achievement => achievement.accessories.map(accessory => accessory.atlasName)), atlasCellSizes: chatPetAchievements.flatMap(achievement => achievement.accessories.map(accessory => accessory.atlasCellSize ?? 64)), rewardCounts: chatPetAchievements.map(achievement => achievement.accessories.length), @@ -1087,7 +1098,7 @@ suite('ChatPetWidget', () => { disabledAchievementIds: disabledChatPetAchievements.map(achievement => achievement.id), disabledAccessoryIds: disabledChatPetAchievements.flatMap(achievement => achievement.accessories.map(accessory => accessory.id)), }, { - count: 6, + count: 14, achievementIds: [ ChatPetAchievementIds.RequestRevision, ChatPetAchievementIds.FirstChatMessage, @@ -1095,6 +1106,14 @@ suite('ChatPetWidget', () => { ChatPetAchievementIds.ModelSwitch, ChatPetAchievementIds.McpServerPresent, ChatPetAchievementIds.CustomSkillPresent, + ChatPetAchievementIds.AgentsWindowOpened, + ChatPetAchievementIds.CreatePullRequest, + ChatPetAchievementIds.AgentEditKept, + ChatPetAchievementIds.SessionArchived, + ChatPetAchievementIds.AgentChangesReviewed, + ChatPetAchievementIds.ChatReferenceOpened, + ChatPetAchievementIds.UsefulOutputCopied, + ChatPetAchievementIds.AutopilotEnabled, ], accessoryIds: [ ChatPetAccessoryIds.TopHatMonocle, @@ -1103,7 +1122,16 @@ suite('ChatPetWidget', () => { ChatPetAccessoryIds.ConstructionHardHat, ChatPetAccessoryIds.FirefighterHelmet, ChatPetAccessoryIds.Crown, + ChatPetAccessoryIds.PropellerHat, + ChatPetAccessoryIds.DarkSailorHat, + ChatPetAccessoryIds.WhiteChefHat, + ChatPetAccessoryIds.SantaHat, + ChatPetAccessoryIds.RiceHat, + ChatPetAccessoryIds.StrawHat, + ChatPetAccessoryIds.PinkPartyHat, + ChatPetAccessoryIds.WizardHat, ], + uniqueAccessoryCount: 14, atlasNames: [ 'grand-top-hat-monocle', 'cowboy-hat', @@ -1111,28 +1139,129 @@ suite('ChatPetWidget', () => { 'construction-hard-hat', 'firefighter-helmet', 'crown', + 'propeller-hat', + 'dark-sailor-hat', + 'white-chef-hat', + 'santa-hat', + 'rice-hat', + 'straw-hat', + 'pink-party-hat', + 'wizard-hat', ], - atlasCellSizes: Array(6).fill(96), - rewardCounts: Array(6).fill(1), + atlasCellSizes: Array(14).fill(96), + rewardCounts: Array(14).fill(1), coversAntennae: true, crownAccessoryId: 'crown', disabledAchievementIds: [ ChatPetAchievementIds.InstructionPresent, ChatPetAchievementIds.QueueOrSteeringMessage, - ChatPetAchievementIds.AgentsWindowOpened, ChatPetAchievementIds.ChatOutputCopied, ChatPetAchievementIds.ImageRequest, ], disabledAccessoryIds: [ ChatPetAccessoryIds.SailorHat, ChatPetAccessoryIds.SpinnerHat, - ChatPetAccessoryIds.VikingHelmet, ChatPetAccessoryIds.PartyHat, ChatPetAccessoryIds.ArtistBeret, ], }); }); + test('keeps legacy disabled hats out of the enabled catalog', () => { + const enabledAccessoryIds = new Set(chatPetAchievements.flatMap(achievement => achievement.accessories.map(accessory => accessory.id))); + const disabledAccessoryIds = new Set(disabledChatPetAchievements.flatMap(achievement => achievement.accessories.map(accessory => accessory.id))); + const legacyDisabledAccessoryIds = [ + ChatPetAccessoryIds.SailorHat, + ChatPetAccessoryIds.SpinnerHat, + ChatPetAccessoryIds.PartyHat, + ChatPetAccessoryIds.ArtistBeret, + ]; + + assert.deepStrictEqual(legacyDisabledAccessoryIds.map(id => ({ + id, + enabled: enabledAccessoryIds.has(id), + disabled: disabledAccessoryIds.has(id), + })), legacyDisabledAccessoryIds.map(id => ({ id, enabled: false, disabled: true }))); + }); + + test('maps every newly added hat to a distinct achievement', () => { + const achievementIds = [ + ChatPetAchievementIds.SessionArchived, + ChatPetAchievementIds.AgentChangesReviewed, + ChatPetAchievementIds.ChatReferenceOpened, + ChatPetAchievementIds.UsefulOutputCopied, + ChatPetAchievementIds.AutopilotEnabled, + ChatPetAchievementIds.AgentsWindowOpened, + ChatPetAchievementIds.CreatePullRequest, + ChatPetAchievementIds.AgentEditKept, + ]; + + assert.deepStrictEqual({ + firstMessageRewards: getChatPetAchievement(ChatPetAchievementIds.FirstChatMessage).accessories.map(accessory => accessory.id), + newAchievements: achievementIds.map(id => { + const achievement = getChatPetAchievement(id); + return { title: achievement.title, reward: achievement.accessories[0].id }; + }), + }, { + firstMessageRewards: [ChatPetAccessoryIds.CowboyHat], + newAchievements: [ + { title: 'Wrapped Up', reward: ChatPetAccessoryIds.SantaHat }, + { title: 'Trust but Verify', reward: ChatPetAccessoryIds.RiceHat }, + { title: 'Follow the Trail', reward: ChatPetAccessoryIds.StrawHat }, + { title: 'Copy That', reward: ChatPetAccessoryIds.PinkPartyHat }, + { title: 'Party Mode', reward: ChatPetAccessoryIds.WizardHat }, + { title: 'Mission Control', reward: ChatPetAccessoryIds.PropellerHat }, + { title: 'Ship it', reward: ChatPetAccessoryIds.DarkSailorHat }, + { title: 'Let it cook', reward: ChatPetAccessoryIds.WhiteChefHat }, + ], + }); + }); + + test('rewards keeping agent edits with the white chef hat', () => { + const letItCook = getChatPetAchievement(ChatPetAchievementIds.AgentEditKept); + + assert.deepStrictEqual({ + title: letItCook.title, + description: letItCook.description, + hint: letItCook.hint, + accessoryIds: letItCook.accessories.map(accessory => accessory.id), + }, { + title: 'Let it cook', + description: 'You kept a change prepared by Chat.', + hint: 'Give a good idea time to come together.', + accessoryIds: [ChatPetAccessoryIds.WhiteChefHat], + }); + }); + + test('rewards Create PR with the dark sailor hat and the Agents window with the propeller hat', () => { + const shipIt = getChatPetAchievement(ChatPetAchievementIds.CreatePullRequest); + const missionControl = getChatPetAchievement(ChatPetAchievementIds.AgentsWindowOpened); + + assert.deepStrictEqual({ + shipIt: { + title: shipIt.title, + description: shipIt.description, + hint: shipIt.hint, + accessoryIds: shipIt.accessories.map(accessory => accessory.id), + }, + missionControl: { + title: missionControl.title, + accessoryIds: missionControl.accessories.map(accessory => accessory.id), + }, + }, { + shipIt: { + title: 'Ship it', + description: 'You used Create PR in the Agents window.', + hint: 'When the changes are ready, send them on their way.', + accessoryIds: [ChatPetAccessoryIds.DarkSailorHat], + }, + missionControl: { + title: 'Mission Control', + accessoryIds: [ChatPetAccessoryIds.PropellerHat], + }, + }); + }); + test('rewards model changes with the hard hat and custom skills with the crown', () => { const modelSwitch = getChatPetAchievement(ChatPetAchievementIds.ModelSwitch); const customSkill = getChatPetAchievement(ChatPetAchievementIds.CustomSkillPresent); @@ -1196,6 +1325,14 @@ suite('ChatPetWidget', () => { ChatPetAccessoryIds.ConstructionHardHat, ChatPetAccessoryIds.FirefighterHelmet, ChatPetAccessoryIds.Crown, + ChatPetAccessoryIds.PropellerHat, + ChatPetAccessoryIds.DarkSailorHat, + ChatPetAccessoryIds.WhiteChefHat, + ChatPetAccessoryIds.SantaHat, + ChatPetAccessoryIds.RiceHat, + ChatPetAccessoryIds.StrawHat, + ChatPetAccessoryIds.PinkPartyHat, + ChatPetAccessoryIds.WizardHat, ], }); }); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAccessoryRig.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAccessoryRig.fixture.ts index 502f7030c8b..21325356de9 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAccessoryRig.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAccessoryRig.fixture.ts @@ -66,13 +66,33 @@ const productionAccessoryPreviews: readonly IChatPetProductionAccessoryPreview[] accessoryId: ChatPetAccessoryIds.CowboyHat, shape: 'Low rounded crown with a broad curved brim', }, + { + accessoryId: ChatPetAccessoryIds.StrawHat, + shape: 'Tall golden crown with a red band and asymmetric brim', + }, + { + accessoryId: ChatPetAccessoryIds.RiceHat, + shape: 'Wide tiered conical hat with warm gold shading', + }, + { + accessoryId: ChatPetAccessoryIds.PinkPartyHat, + shape: 'Pink leaning party cone with a gold pom', + }, + { + accessoryId: ChatPetAccessoryIds.SantaHat, + shape: 'Long red stocking cap with white trim and pom', + }, { accessoryId: ChatPetAccessoryIds.BaseballCap, shape: 'Paneled red crown with a long side-facing bill', }, + { + accessoryId: ChatPetAccessoryIds.PropellerHat, + shape: 'Multicolor beanie with a full-width gold propeller', + }, { accessoryId: ChatPetAccessoryIds.TopHatMonocle, - shape: 'Extra-tall squared crown with a full-width brim', + shape: 'Extra-tall striped crown with a full-width brim and monocle', }, { accessoryId: ChatPetAccessoryIds.PartyHat, @@ -82,6 +102,10 @@ const productionAccessoryPreviews: readonly IChatPetProductionAccessoryPreview[] accessoryId: ChatPetAccessoryIds.SailorHat, shape: 'White Dixie-cup cap with a balanced crown and subtle forward brim', }, + { + accessoryId: ChatPetAccessoryIds.DarkSailorHat, + shape: 'White sailor cap with a dark band and gold accent', + }, { accessoryId: ChatPetAccessoryIds.SpinnerHat, shape: 'Domed beanie with a wide multicolor propeller', @@ -90,18 +114,22 @@ const productionAccessoryPreviews: readonly IChatPetProductionAccessoryPreview[] accessoryId: ChatPetAccessoryIds.ConstructionHardHat, shape: 'Low ribbed safety dome with a full-width brim', }, + { + accessoryId: ChatPetAccessoryIds.WhiteChefHat, + shape: 'Tall white toque with a pleated lower crown', + }, { accessoryId: ChatPetAccessoryIds.FirefighterHelmet, shape: 'Rounded red helmet with a gold shield and neck guard', }, - { - accessoryId: ChatPetAccessoryIds.VikingHelmet, - shape: 'Balanced steel helmet with a longer forward horn and short nose guard', - }, { accessoryId: ChatPetAccessoryIds.Crown, shape: 'Gold crown with tall points and jewel highlights', }, + { + accessoryId: ChatPetAccessoryIds.WizardHat, + shape: 'Wide purple leaning hat with a floating gold star', + }, { accessoryId: ChatPetAccessoryIds.ArtistBeret, shape: 'Tilted berry beret with a raised stem and dark band', @@ -417,7 +445,7 @@ async function renderAllRuntimeStates(ctx: ComponentFixtureContext): Promise { configureChatPetFixtureFileRoot(ctx.disposableStore); ctx.container.style.width = '900px'; - ctx.container.style.height = '1080px'; + ctx.container.style.height = '1320px'; ctx.container.style.boxSizing = 'border-box'; ctx.container.style.padding = '24px'; ctx.container.style.overflow = 'auto'; @@ -514,7 +542,7 @@ async function renderAllAccessoriesFacing(ctx: ComponentFixtureContext): Promise async function renderCoveredAntennaeComparison(ctx: ComponentFixtureContext): Promise { configureChatPetFixtureFileRoot(ctx.disposableStore); ctx.container.style.width = '1240px'; - ctx.container.style.height = '1000px'; + ctx.container.style.height = '2240px'; ctx.container.style.boxSizing = 'border-box'; ctx.container.style.padding = '24px'; ctx.container.style.overflow = 'auto'; @@ -525,7 +553,7 @@ async function renderCoveredAntennaeComparison(ctx: ComponentFixtureContext): Pr heading.textContent = 'Production accessory motion'; heading.style.margin = '0 0 8px'; const description = DOM.append(ctx.container, DOM.$('p')); - description.textContent = 'All 11 achievement rewards use body-owned attachment tracks and transparent antenna occlusion in both directions.'; + description.textContent = `All ${productionAccessoryPreviews.length} achievement rewards use body-owned attachment tracks and transparent antenna occlusion in both directions.`; description.style.margin = '0 0 20px'; description.style.color = 'var(--vscode-descriptionForeground)'; diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index c4c1c6a1a2d..b23f865bf27 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -13,40 +13,40 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/17f7907ede552371d2164be2b4f346496890646e966eac2f7eef50c9c81b5f9f) #### chat/chatPetAccessoryRig/chatPetAccessoryRig/AllAccessoriesFacing/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/5b70ae9273fabf3a5302943c59cecd674f1f7fe7b7b08168af3e684e8c4ab6d2) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/f80557da23a56b2a35a580be984ecc168b4ea1434e2320f43563014413ce68b9) #### chat/chatPetAccessoryRig/chatPetAccessoryRig/AllAccessoriesFacing/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/8ace11f4873c8750a65891b7b4cd90bdba5967fcd8e2bdc05c211e90747b5246) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/879a0ea1e9e176db00263651434f28173bdaac465524e16d307cc233d8012329) #### chat/chatPetAccessoryRig/chatPetAccessoryRig/AllRuntimeStates/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/45f421e7d9c4e3d90a0e7c24111f5398a763047690752234c38f749d81feda54) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/5a3aac45723f1c2c9b91aed7942ef321283cc4fb9efdd9418d87db7fa53a92d4) #### chat/chatPetAccessoryRig/chatPetAccessoryRig/AllRuntimeStates/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/f2c790c0a0217d9183a3301442f9e9cbc4cf67434ec1a8c9ca47241ccfe99e5b) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/52a7631f8a6714054e07db345ae85b18029e958476e99d6b79d0741d07784bee) #### chat/chatPetAccessoryRig/chatPetAccessoryRig/CoveredAntennaeComparison/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/5da807336d09dc733ea7ba4b64a45df31d18b2ce5807f1452ffaef059023e406) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/ea75efef839b33d33c39e2b11b79a56cecf2ac1e0456e938121e4fe909e6a8f6) #### chat/chatPetAccessoryRig/chatPetAccessoryRig/CoveredAntennaeComparison/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/2ed413d9ae6bc99d5899d17fda368d98133be656a064f91823234eac09feeff6) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/e9c81aa755f1701c0409ba5d55e0010b3285a70faf511b8fc15a6429df11c9d5) #### chat/chatPetAccessoryRig/chatPetAccessoryRig/CriticalPoses/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/3c3b795d69792d9511ccd7b155b8bc34f3fce90784c4133b5ea69d2dc2771edf) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/35b8feb0149c04b7cc6e040a2f3708ee6a5b0f9f45955faddcefa4d712c3e9ca) #### chat/chatPetAccessoryRig/chatPetAccessoryRig/CriticalPoses/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/7d5e729d3d22043a73536614d9ee2b0f152f79482c96eed0300825022bdd6143) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/850e813a535b920e614f45c370874ea13e39b095d9930d51863afdb5c03abacd) #### chat/chatPetAccessoryRig/chatPetAccessoryRig/LiveEyeLayering/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/bbb2bc08101056301767c63bab777ff343651540cd4aad860fd20a17c0442991) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/9e0a4303affb565bbf45c070f9db92ea9478e02bf42de15fcc5fde30b85d7a50) #### chat/chatPetAccessoryRig/chatPetAccessoryRig/LiveEyeLayering/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/df1f42cc6f6a3eb52f36effd880cfc010b8107fccb88dcbeffbf57ae145ca2e4) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/28743055f10abdf3c0a7809b2b3e830b7a04dc65157febf48215eecdf8b03772) #### chat/petAchievements/standaloneModal/chatPetAchievementsEditor/MixedSelected/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/987887f3edd330dfdcf3e9cb2164b03046eb6858780a98175bb8d4e3e089b4fa) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/c50e9db57717e9e78b21f3ec4b61741bd5a5a05ef4d376bf057ff6b782edcc86) #### chat/petAchievements/standaloneModal/chatPetAchievementsEditor/MixedSelected/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/4c34a203cbbdad71b881f3a5aa2f715dfb50ed356dbf19e18e4a79b42f5f18fa) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/203f75a7c70b45f4ace8c5d99e9e297ad4fa7925c2fef3d1ecf4d760598cf044) #### editor/codeEditor/CodeEditor/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/09075b2f4715fa8a8ad426165bb85ba96a15b7174259c7da7ef0c2d5e74f7f79) @@ -91,7 +91,7 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/a29cfc0bf4510b57c82d9eae0d974babe7035042456326be861308cae609a1b5) #### sessions/accountMenu/petAchievementBadges/chatPetAchievementBadges/AllBadges/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/ae6b8d79a5e88a93388fe24ca96cc5524145815a1628d844d3ff8357d40141f6) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/ecb2b4220e94da5b699c635688e3b9a5a0c7e7c7d7bbde0ee368cf3ec88c5113) #### sessions/accountMenu/petAchievementBadges/chatPetAchievementBadges/AllBadges/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/5cf9c737fbdbf76a5f8cbf0c40d87b0877e8633fcf432c1ac08533c89876f754) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/e38666f452675ff6e3fe521c4e02861e7cc628ad787e06206d929d2c1f6a8cf6) From 27fb2128fb32f2b8aeab48618517189b724bff52 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:20:51 +0200 Subject: [PATCH 043/116] Split session artifacts into artifacts and references (#332687) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Split session artifacts into artifacts and references An artifact is something the session produced that is not an ordinary workspace edit: a pull request or issue it opened, a plan file written outside the workspace, another side effect of its work. A reference is something it did not produce but the user should look at because of the task: the pull request or commit that introduced a bug, a relevant page. The agent now says which it recorded through a mandatory `isArtifact` flag, so the tools are renamed to `add_artifact_or_reference`, `remove_artifact_or_reference` and `list_artifacts_and_references`, and the per-type `createdByThisSession` field is gone — being produced by the session is the definition of an artifact. Entries persisted before this change read back as artifacts. Tool calls render "Added artifact" or "Added reference" from the flag; removal takes its wording from what it actually removed. References get their own pill, always summarized as a count, placed directly after the artifacts pill so the two read as a pair. Only artifacts are promoted into the pull request and issue pills, which poll GitHub, but a reference keeps its link identity so anything those pills already show is offered exactly once. Along the way: - The artifacts pill no longer collapses into a lone entry unless it is a file, whose name and themed icon say what it is; every other single artifact stays behind "1 Artifact" so the row keeps a stable shape. `alwaysSummarize` becomes a `ChatPillSingleEntry` policy interpreted in one place. - Parsing GitHub issue and pull request references out of user messages is removed. It fed the same pills from a second source, which would show a recorded reference twice, and it guessed at intent the agent can now state outright. - A `uri` the client cannot open is rejected when it is recorded, instead of being reported as added and then appearing in no pill at all. - Reading persisted artifacts reports what it could not parse, so a corrupt row no longer empties a session's artifacts without a trace. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback on artifacts and references - `isArtifact` accepts only a boolean, with an absent field as the sole legacy default. A malformed value such as `"false"` or `null` no longer reads back as an artifact; the entry is rejected and counted among the dropped rows the caller warns about. - A recorded `uri` is validated by the client's own strict `URI.parse` rather than a hand-rolled pattern, so the two can no longer disagree. The previous check accepted values like `foo/bar:baz`, which the client then failed to open, leaving the entry in no pill at all. - Renamed server tools keep answering to the names they were advertised under. `IServerToolGroup` gains `legacyToolNames`, the host translates a legacy name before dispatching, and the display path falls back to it once no advertised tool matches — so restored history and prompts written against `add_artifact` still route and still render. Groups only ever see their current names. - The Sessions and agent host provider specifications now state that `ISession.artifacts` carries both categories and that consumers must use `isArtifact` to tell them apart. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use the bookmark icon for the references pill Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/agentHostGitStateService.ts | 3 - .../agentHost/common/githubIssueReferences.ts | 71 ++----- .../common/githubPullRequestReferences.ts | 64 ------ .../agentHost/common/serverToolNames.ts | 17 +- .../common/sessionArtifactCollection.ts | 59 ++++-- .../agentHost/common/sessionArtifacts.ts | 82 +++++--- .../agentHost/common/state/sessionState.ts | 18 -- .../node/agentHostGitStateService.ts | 38 +--- .../platform/agentHost/node/agentService.ts | 21 +- .../githubReferencesContribution.ts | 9 +- .../node/shared/agentServerToolHost.ts | 43 +++- .../node/shared/artifactServerTools.ts | 109 ++++++---- .../agentHost/node/shared/serverToolGroups.ts | 12 ++ .../test/common/githubIssueReferences.test.ts | 32 --- .../githubPullRequestReferences.test.ts | 37 ---- .../test/common/sessionArtifacts.test.ts | 100 +++++++-- .../test/common/sessionTestHelpers.ts | 1 - .../agentHostChangesetCoordinator.test.ts | 1 - ...agentHostChangesetOperationService.test.ts | 1 - .../test/node/agentHostContributions.test.ts | 1 - .../node/agentHostGitStateService.test.ts | 193 +----------------- .../agentHostMergeOperationProvider.test.ts | 1 - ...ntHostPullRequestOperationProvider.test.ts | 1 - .../test/node/agentSideEffects.test.ts | 66 ------ .../test/node/artifactServerTools.test.ts | 68 ++++++ .../test/node/chatContributions.test.ts | 29 --- src/vs/sessions/SESSIONS.md | 4 +- .../contrib/chat/browser/sessionArtifacts.ts | 43 +++- .../chat/browser/sessionChatInputToolbar.ts | 44 ++-- .../chat/browser/sessionCustomizations.ts | 4 +- .../contrib/chat/common/sessionChatPills.ts | 3 + .../test/browser/sessionArtifacts.test.ts | 16 +- .../chat/test/common/sessionChatPills.test.ts | 3 + .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 2 +- .../browser/agentHostSessionArtifacts.ts | 59 +++--- .../browser/baseAgentHostSessionsProvider.ts | 40 ++-- .../localAgentHostSessionsProvider.test.ts | 30 +-- .../services/sessions/common/session.ts | 14 +- src/vs/workbench/browser/chatDropdownPill.ts | 55 +++-- .../chat/browser/widget/chatTurnPills.ts | 18 +- .../test/browser/widget/chatTurnPills.test.ts | 30 +++ .../sessionChatInputToolbar.fixture.ts | 39 +++- 42 files changed, 693 insertions(+), 788 deletions(-) delete mode 100644 src/vs/platform/agentHost/common/githubPullRequestReferences.ts delete mode 100644 src/vs/platform/agentHost/test/common/githubIssueReferences.test.ts delete mode 100644 src/vs/platform/agentHost/test/common/githubPullRequestReferences.test.ts create mode 100644 src/vs/platform/agentHost/test/node/artifactServerTools.test.ts diff --git a/src/vs/platform/agentHost/common/agentHostGitStateService.ts b/src/vs/platform/agentHost/common/agentHostGitStateService.ts index ee958fe4a12..f8373e1815e 100644 --- a/src/vs/platform/agentHost/common/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/common/agentHostGitStateService.ts @@ -57,7 +57,4 @@ export interface IAgentHostGitStateService { * @param workingDirectory Optional working directory override; when omitted, the session summary's working directory is used. */ attachSessionGitHubPullRequest(sessionKey: string, workingDirectory?: URI): Promise; - - /** Adds GitHub issues and pull requests referenced in a user message to the session. */ - attachSessionGitHubReferences(sessionKey: string, text: string): Promise; } diff --git a/src/vs/platform/agentHost/common/githubIssueReferences.ts b/src/vs/platform/agentHost/common/githubIssueReferences.ts index c2980802738..958862a3840 100644 --- a/src/vs/platform/agentHost/common/githubIssueReferences.ts +++ b/src/vs/platform/agentHost/common/githubIssueReferences.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/** A GitHub issue referenced from a user message. */ +/** A GitHub issue, identified by the repository that owns it and its number. */ export interface IGitHubIssueReference { readonly owner: string; readonly repo: string; @@ -11,64 +11,19 @@ export interface IGitHubIssueReference { } /** - * Matches `https://github.com/{owner}/{repo}/issues/{number}`, optionally with a - * `www.` host, a trailing slash, a query string or a fragment (e.g. the - * `#issuecomment-123` anchor GitHub appends when copying a comment link). + * Matches `https://github.com/{owner}/{repo}/issues/{number}` from the start of + * the string, optionally with a `www.` host. The trailing boundary lets a URL + * keep a trailing slash, query string or fragment (e.g. the `#issuecomment-123` + * anchor GitHub appends when copying a comment link). */ -const ISSUE_URL_PATTERN = /\bhttps?:\/\/(?:www\.)?github\.com\/([\w.-]+)\/([\w.-]+)\/issues\/(\d+)\b/gi; +const ISSUE_URL_PATTERN = /^https?:\/\/(?:www\.)?github\.com\/([\w.-]+)\/([\w.-]+)\/issues\/(\d+)\b/i; -/** - * Matches the cross-repository shorthand `{owner}/{repo}#{number}`. The leading - * boundary check rejects references that are part of a longer path (e.g. the - * `microsoft/vscode#1` inside a URL, which the URL pattern already covers). - */ -const ISSUE_SHORTHAND_PATTERN = /(?(); - - const add = (owner: string, repo: string, rawNumber: string): void => { - const number = Number(rawNumber); - if (!Number.isSafeInteger(number) || number <= 0) { - return; - } - const url = toGitHubIssueUrl({ owner, repo, number }); - if (seen.has(url)) { - return; - } - seen.add(url); - references.push({ owner, repo, number }); - }; - - for (const match of text.matchAll(ISSUE_URL_PATTERN)) { - add(match[1], match[2], match[3]); - } - for (const match of text.matchAll(ISSUE_SHORTHAND_PATTERN)) { - add(match[1], match[2], match[3]); - } - - return references; -} - -/** Builds the canonical `github.com` URL for an issue reference. */ -export function toGitHubIssueUrl(reference: IGitHubIssueReference): string { - return `https://github.com/${reference.owner}/${reference.repo}/issues/${reference.number}`; -} - -/** Parses a canonical GitHub issue URL back into its parts, or `undefined`. */ +/** Parses a GitHub issue URL into its parts, or `undefined` when it is not one. */ export function parseGitHubIssueUrl(url: string): IGitHubIssueReference | undefined { - return parseGitHubIssueReferences(url)[0]; + const match = ISSUE_URL_PATTERN.exec(url); + if (!match) { + return undefined; + } + const number = Number(match[3]); + return Number.isSafeInteger(number) && number > 0 ? { owner: match[1], repo: match[2], number } : undefined; } diff --git a/src/vs/platform/agentHost/common/githubPullRequestReferences.ts b/src/vs/platform/agentHost/common/githubPullRequestReferences.ts deleted file mode 100644 index 8a3a698e5d3..00000000000 --- a/src/vs/platform/agentHost/common/githubPullRequestReferences.ts +++ /dev/null @@ -1,64 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** A GitHub pull request referenced from a user message. */ -export interface IGitHubPullRequestReference { - readonly owner: string; - readonly repo: string; - readonly number: number; -} - -const PULL_REQUEST_URL_PATTERN = /\bhttps?:\/\/(?[\w.-]+)\/(?[\w.-]+)\/(?[\w.-]+)\/pull\/(?\d+)\b/gi; -const PULL_REQUEST_SHORTHAND_PATTERN = /\b(?:PR|pull request)\s*#(?\d+)\b/gi; - -/** Extracts unambiguous GitHub pull request references without duplicates. */ -export function parseGitHubPullRequestReferences(text: string, defaultRepository?: { readonly owner: string; readonly repo: string }, gitHubHost = 'github.com'): IGitHubPullRequestReference[] { - const candidates: (IGitHubPullRequestReference & { readonly index: number })[] = []; - const references: IGitHubPullRequestReference[] = []; - const seen = new Set(); - const normalizedGitHubHost = normalizeGitHubHost(gitHubHost); - - const addCandidate = (index: number, owner: string, repo: string, rawNumber: string): void => { - const number = Number(rawNumber); - if (!Number.isSafeInteger(number) || number <= 0) { - return; - } - candidates.push({ index, owner, repo, number }); - }; - - for (const match of text.matchAll(PULL_REQUEST_URL_PATTERN)) { - if (match.groups && normalizeGitHubHost(match.groups.host) === normalizedGitHubHost) { - addCandidate(match.index, match.groups.owner, match.groups.repo, match.groups.number); - } - } - if (defaultRepository) { - for (const match of text.matchAll(PULL_REQUEST_SHORTHAND_PATTERN)) { - if (match.groups) { - addCandidate(match.index, defaultRepository.owner, defaultRepository.repo, match.groups.number); - } - } - } - - for (const candidate of candidates.sort((a, b) => a.index - b.index)) { - const { owner, repo, number } = candidate; - const reference = { owner, repo, number }; - const url = toGitHubPullRequestUrl(reference, gitHubHost).toLowerCase(); - if (!seen.has(url)) { - seen.add(url); - references.push(reference); - } - } - - return references; -} - -function normalizeGitHubHost(host: string): string { - return host.toLowerCase().replace(/^www\./, ''); -} - -/** Builds the canonical URL for a pull request reference on the configured GitHub host. */ -export function toGitHubPullRequestUrl(reference: IGitHubPullRequestReference, gitHubHost = 'github.com'): string { - return `https://${normalizeGitHubHost(gitHubHost)}/${reference.owner}/${reference.repo}/pull/${reference.number}`; -} diff --git a/src/vs/platform/agentHost/common/serverToolNames.ts b/src/vs/platform/agentHost/common/serverToolNames.ts index beea8e6ecec..9d0113ae1b1 100644 --- a/src/vs/platform/agentHost/common/serverToolNames.ts +++ b/src/vs/platform/agentHost/common/serverToolNames.ts @@ -29,7 +29,18 @@ export const enum SessionServerToolName { /** Names of the artifact server tools, shared between `common/` and `node/`. */ export const enum ArtifactServerToolName { - AddArtifact = 'add_artifact', - RemoveArtifact = 'remove_artifact', - ListArtifacts = 'list_artifacts', + AddArtifactOrReference = 'add_artifact_or_reference', + RemoveArtifactOrReference = 'remove_artifact_or_reference', + ListArtifactsAndReferences = 'list_artifacts_and_references', } + +/** + * The names these tools were advertised under before they also recorded + * references, mapped to their replacement. Restored history and prompts written + * against the old names keep routing and keep their display. + */ +export const LEGACY_ARTIFACT_SERVER_TOOL_NAMES: ReadonlyMap = new Map([ + ['add_artifact', ArtifactServerToolName.AddArtifactOrReference as string], + ['remove_artifact', ArtifactServerToolName.RemoveArtifactOrReference as string], + ['list_artifacts', ArtifactServerToolName.ListArtifactsAndReferences as string], +]); diff --git a/src/vs/platform/agentHost/common/sessionArtifactCollection.ts b/src/vs/platform/agentHost/common/sessionArtifactCollection.ts index 7cdef0d402e..cfee8db9b5e 100644 --- a/src/vs/platform/agentHost/common/sessionArtifactCollection.ts +++ b/src/vs/platform/agentHost/common/sessionArtifactCollection.ts @@ -3,22 +3,24 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { URI } from '../../../base/common/uri.js'; import { getSessionArtifactValue, isGitHubArtifactLink, SESSION_ARTIFACT_TYPES, SessionArtifactType, type ISessionArtifact } from './sessionArtifacts.js'; -/** The fields an agent supplies when adding an artifact. */ +/** The fields an agent supplies when adding an artifact or reference. */ export interface ISessionArtifactInput { readonly type: SessionArtifactType; readonly label: string; + /** `true` for an artifact the session produced, `false` for a reference. */ + readonly isArtifact: boolean; readonly link?: string; readonly uri?: string; readonly commitHash?: string; - readonly createdByThisSession?: boolean; } export interface IAddSessionArtifactResult { readonly artifacts: readonly ISessionArtifact[]; readonly artifact: ISessionArtifact; - /** `false` when an artifact with the same value already existed. */ + /** `false` when an entry with the same value already existed. */ readonly added: boolean; } @@ -57,7 +59,28 @@ function requireWebLink(value: unknown, field: string, toolName: string): string return link; } -/** Validates and normalizes raw `add_artifact` arguments. */ +/** + * The client opens a `uri` by parsing it strictly, so anything it cannot parse + * would be recorded, reported as added, and then quietly appear in no pill at + * all. Parsing it the same way here gives the agent an error it can act on + * instead. A single-letter scheme is a Windows drive path (`C:\repo\plan.md`), + * which parses into a nonsense URI rather than failing, so it is rejected too. + */ +function requireUri(value: unknown, field: string, toolName: string): string { + const uri = requireString(value, field, toolName); + let scheme: string | undefined; + try { + scheme = URI.parse(uri, /*strict*/ true).scheme; + } catch { + scheme = undefined; + } + if (!scheme || scheme.length === 1) { + throw new Error(`Invalid ${toolName} input: ${field} must be an absolute URI including its scheme, such as 'file:///path/to/file' — not a plain file system path.`); + } + return uri; +} + +/** Validates and normalizes raw `add_artifact_or_reference` arguments. */ export function parseSessionArtifactInput(rawArgs: unknown, toolName: string): ISessionArtifactInput { if (!rawArgs || typeof rawArgs !== 'object' || Array.isArray(rawArgs)) { throw new Error(`Invalid ${toolName} input: expected an object.`); @@ -67,34 +90,33 @@ export function parseSessionArtifactInput(rawArgs: unknown, toolName: string): I if (typeof type !== 'string' || !(SESSION_ARTIFACT_TYPES as readonly string[]).includes(type)) { throw new Error(`Invalid ${toolName} input: type must be one of ${SESSION_ARTIFACT_TYPES.join(', ')}.`); } + if (typeof args['isArtifact'] !== 'boolean') { + throw new Error(`Invalid ${toolName} input: isArtifact must be a boolean — true for something this session produced, false for a reference.`); + } const artifactType = type as SessionArtifactType; - const input: { type: SessionArtifactType; label: string; link?: string; uri?: string; commitHash?: string; createdByThisSession?: boolean } = { + const input: { type: SessionArtifactType; label: string; isArtifact: boolean; link?: string; uri?: string; commitHash?: string } = { type: artifactType, label: requireString(args['label'], 'label', toolName), + isArtifact: args['isArtifact'], }; if (linkTypes.has(artifactType)) { input.link = requireWebLink(args['link'], 'link', toolName); } if (uriTypes.has(artifactType)) { - input.uri = requireString(args['uri'], 'uri', toolName); + input.uri = requireUri(args['uri'], 'uri', toolName); } if (artifactType === SessionArtifactType.Commit) { input.commitHash = requireString(args['commitHash'], 'commitHash', toolName); } - if (artifactType === SessionArtifactType.PullRequest) { - if (typeof args['createdByThisSession'] !== 'boolean') { - throw new Error(`Invalid ${toolName} input: createdByThisSession must be a boolean for pull request artifacts.`); - } - input.createdByThisSession = args['createdByThisSession']; - } return input; } /** - * The artifacts recorded on a session. Immutable: mutations return the next - * list so callers stay in control of persisting and publishing it. + * The artifacts and references recorded on a session. Immutable: mutations + * return the next list so callers stay in control of persisting and publishing + * it. */ export class SessionArtifactCollection { @@ -105,8 +127,8 @@ export class SessionArtifactCollection { } /** - * Adds an artifact unless one with the same value already exists, in which - * case the existing artifact is returned unchanged. + * Adds an artifact or reference unless one with the same value already + * exists, in which case the existing entry is returned unchanged. */ add(input: ISessionArtifactInput, createId: () => string): IAddSessionArtifactResult { const artifact = this._create(input, createId); @@ -131,18 +153,17 @@ export class SessionArtifactCollection { id: string; type: SessionArtifactType; label: string; + isArtifact: boolean; link?: string; uri?: string; commitHash?: string; isGitHub?: boolean; - createdByThisSession?: boolean; - } = { id: createId(), type: input.type, label: input.label }; + } = { id: createId(), type: input.type, label: input.label, isArtifact: input.isArtifact }; if (input.link !== undefined) { artifact.link = input.link; } if (input.uri !== undefined) { artifact.uri = input.uri; } if (input.commitHash !== undefined) { artifact.commitHash = input.commitHash; } if (input.link !== undefined && gitHubTypes.has(input.type)) { artifact.isGitHub = isGitHubArtifactLink(input.link); } - if (input.createdByThisSession !== undefined) { artifact.createdByThisSession = input.createdByThisSession; } return artifact; } } diff --git a/src/vs/platform/agentHost/common/sessionArtifacts.ts b/src/vs/platform/agentHost/common/sessionArtifacts.ts index 41e0d7ca599..58cd7fc851d 100644 --- a/src/vs/platform/agentHost/common/sessionArtifacts.ts +++ b/src/vs/platform/agentHost/common/sessionArtifacts.ts @@ -6,8 +6,10 @@ import type { SessionSummaryMeta } from './state/sessionState.js'; /** - * Artifact kinds an agent can record on its session. Each kind carries the one - * field the client needs to open it, plus a label. + * The kinds an agent can record on its session, as either an artifact (the + * session produced it) or a reference (the session found it worth returning + * to). Each kind carries the one field the client needs to open it, plus a + * label. */ export const enum SessionArtifactType { PullRequest = 'pullRequest', @@ -27,26 +29,30 @@ export const SESSION_ARTIFACT_TYPES: readonly SessionArtifactType[] = [ SessionArtifactType.Resource, ]; -/** A session artifact as stored by the host and published to clients. */ +/** A session artifact or reference as stored by the host and published to clients. */ export interface ISessionArtifact { readonly id: string; readonly type: SessionArtifactType; readonly label: string; - /** Link for pull request, issue, commit and website artifacts. */ + /** + * `true` for an artifact — something this session produced — and `false` for + * a reference, something it only points the user at. + */ + readonly isArtifact: boolean; + /** Link for pull request, issue, commit and website entries. */ readonly link?: string; - /** Resource URI for file and resource artifacts. */ + /** Resource URI for file and resource entries. */ readonly uri?: string; - /** Commit hash for commit artifacts. */ + /** Commit hash for commit entries. */ readonly commitHash?: string; /** Whether a pull request or issue link points at GitHub. Host-computed. */ readonly isGitHub?: boolean; - /** Whether this session created the pull request, rather than only referencing it. */ - readonly createdByThisSession?: boolean; } /** * Reserved key under {@link SessionSummaryMeta} holding the session's agent-set - * artifacts. VS Code convention layered on the protocol's generic `_meta` bag. + * artifacts and references. VS Code convention layered on the protocol's + * generic `_meta` bag. */ export const SESSION_META_ARTIFACTS_KEY = 'agentHost/sessionArtifacts'; @@ -62,22 +68,33 @@ function parseSessionArtifact(value: unknown): ISessionArtifact | undefined { if (typeof raw['id'] !== 'string' || typeof raw['label'] !== 'string' || !isSessionArtifactType(raw['type'])) { return undefined; } + // `isArtifact` is mandatory, so only its absence is tolerated — that is an + // entry recorded before artifacts and references were told apart, which was + // always an artifact. Any other value is malformed and rejects the entry. + const isArtifact = raw['isArtifact']; + if (isArtifact !== undefined && typeof isArtifact !== 'boolean') { + return undefined; + } const artifact: { id: string; type: SessionArtifactType; label: string; + isArtifact: boolean; link?: string; uri?: string; commitHash?: string; isGitHub?: boolean; - createdByThisSession?: boolean; - } = { id: raw['id'], type: raw['type'], label: raw['label'] }; + } = { + id: raw['id'], + type: raw['type'], + label: raw['label'], + isArtifact: isArtifact ?? true, + }; if (typeof raw['link'] === 'string') { artifact.link = raw['link']; } if (typeof raw['uri'] === 'string') { artifact.uri = raw['uri']; } if (typeof raw['commitHash'] === 'string') { artifact.commitHash = raw['commitHash']; } if (typeof raw['isGitHub'] === 'boolean') { artifact.isGitHub = raw['isGitHub']; } - if (typeof raw['createdByThisSession'] === 'boolean') { artifact.createdByThisSession = raw['createdByThisSession']; } return artifact; } @@ -113,20 +130,39 @@ export function stringifySessionArtifacts(artifacts: readonly ISessionArtifact[] return JSON.stringify(artifacts); } -/** Parses artifacts previously written by {@link stringifySessionArtifacts}. */ -export function parseSessionArtifacts(value: string | undefined): readonly ISessionArtifact[] { - if (!value) { - return []; - } - try { - return readSessionArtifacts({ [SESSION_META_ARTIFACTS_KEY]: JSON.parse(value) }); - } catch { - return []; - } +/** The outcome of reading persisted artifacts: what was read, and what was lost. */ +export interface IParsedSessionArtifacts { + readonly artifacts: readonly ISessionArtifact[]; + /** Why nothing could be read, when the payload itself was unreadable. */ + readonly error?: Error; + /** How many individual entries were rejected as malformed. */ + readonly dropped: number; } /** - * The value that identifies an artifact for de-duplication: its link, resource + * Parses artifacts previously written by {@link stringifySessionArtifacts}. + * Reports what could not be read rather than silently returning less, so a + * corrupt row does not empty a session's artifacts without a trace. + */ +export function parseSessionArtifacts(value: string | undefined): IParsedSessionArtifacts { + if (!value) { + return { artifacts: [], dropped: 0 }; + } + let raw: unknown; + try { + raw = JSON.parse(value); + } catch (error) { + return { artifacts: [], error: error instanceof Error ? error : new Error(String(error)), dropped: 0 }; + } + if (!Array.isArray(raw)) { + return { artifacts: [], error: new Error('expected an array of artifacts'), dropped: 0 }; + } + const artifacts = readSessionArtifacts({ [SESSION_META_ARTIFACTS_KEY]: raw }); + return { artifacts, dropped: raw.length - artifacts.length }; +} + +/** + * The value that identifies an entry for de-duplication: its link, resource * URI or commit hash, normalized for comparison. */ export function getSessionArtifactValue(artifact: ISessionArtifact): string { diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 645e2dc6815..669507c17ac 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -1490,11 +1490,6 @@ export interface ISessionGitHubState { readonly initialPullRequestUrls?: readonly string[]; /** Pull requests explicitly associated through user intent, most recent first. */ readonly associatedPullRequestUrls?: readonly string[]; - /** - * URLs of the GitHub issues referenced by the session's user messages, in - * order of first appearance. - */ - readonly issueUrls?: readonly string[]; /** * The name of the branch the most recent {@link pullRequestUrls} entry was found (or created) for. * A pull request always relates to a branch: when the working copy switches @@ -1584,17 +1579,6 @@ export function withInitialSessionPullRequest(gitHubState: ISessionGitHubState | }; } -/** Returns state that records a user-referenced pull request without changing checkout PR state. */ -export function withMostRecentReferencedSessionPullRequest(gitHubState: ISessionGitHubState | undefined, pullRequestUrl: string): ISessionGitHubState { - const associatedPullRequestUrls = normalizeSessionPullRequestUrls([ - pullRequestUrl, - ...(gitHubState?.associatedPullRequestUrls ?? []) - ]); - return { - associatedPullRequestUrls, - }; -} - /** * Reads the well-known git-state payload from {@link SessionMeta}, if * present. Returns `undefined` when the meta bag is absent or the value at @@ -1696,7 +1680,6 @@ export function readSessionGitHubState(meta: SessionSummaryMeta | undefined): IS pullRequestUrls?: readonly string[]; initialPullRequestUrls?: readonly string[]; associatedPullRequestUrls?: readonly string[]; - issueUrls?: readonly string[]; pullRequestBranchName?: string; } = {}; @@ -1719,7 +1702,6 @@ export function readSessionGitHubState(meta: SessionSummaryMeta | undefined): IS result.associatedPullRequestUrls = associatedPullRequestUrls; } } - if (Array.isArray(raw['issueUrls'])) { result.issueUrls = raw['issueUrls'].filter((url): url is string => typeof url === 'string'); } if (typeof raw['pullRequestBranchName'] === 'string') { result.pullRequestBranchName = raw['pullRequestBranchName']; } return result; } diff --git a/src/vs/platform/agentHost/node/agentHostGitStateService.ts b/src/vs/platform/agentHost/node/agentHostGitStateService.ts index 90640ebe371..a4a96d461cb 100644 --- a/src/vs/platform/agentHost/node/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitStateService.ts @@ -9,9 +9,7 @@ import { URI } from '../../../base/common/uri.js'; import { Emitter } from '../../../base/common/event.js'; import { ILogService } from '../../log/common/log.js'; import { IAgentHostGitStateService, META_GIT_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../common/agentHostGitStateService.js'; -import { getSessionRelatedPullRequestUrls, ISessionGitHubState, ISessionWithDefaultChat, readSessionGitHubState, readSessionGitState, readSessionSourceControlState, SessionLifecycle, SessionSourceControlOutcome, withInitialSessionPullRequest, withMostRecentReferencedSessionPullRequest, withMostRecentSessionPullRequest, withSessionGitHubState, withSessionGitState, withSessionSourceControlState, type ISessionGitState, type ISessionSourceControlState } from '../common/state/sessionState.js'; -import { MAX_SESSION_ISSUE_REFERENCES, parseGitHubIssueReferences, toGitHubIssueUrl } from '../common/githubIssueReferences.js'; -import { parseGitHubPullRequestReferences, toGitHubPullRequestUrl } from '../common/githubPullRequestReferences.js'; +import { getSessionRelatedPullRequestUrls, ISessionGitHubState, ISessionWithDefaultChat, readSessionGitHubState, readSessionGitState, readSessionSourceControlState, SessionLifecycle, SessionSourceControlOutcome, withInitialSessionPullRequest, withMostRecentSessionPullRequest, withSessionGitHubState, withSessionGitState, withSessionSourceControlState, type ISessionGitState, type ISessionSourceControlState } from '../common/state/sessionState.js'; import { IAgentHostGitService, META_DIFF_BASE_BRANCH, parseUpstreamBranchName, resolveDiffBaseBranchName } from '../common/agentHostGitService.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; import { ISessionDataService } from '../common/sessionDataService.js'; @@ -201,40 +199,6 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi : undefined; } - async attachSessionGitHubReferences(sessionKey: string, text: string): Promise { - const currentState = readSessionGitHubState(this._stateManager.getSessionState(sessionKey)?._meta); - const issueReferences = parseGitHubIssueReferences(text); - const repository = currentState?.owner && currentState.repo ? { owner: currentState.owner, repo: currentState.repo } : undefined; - const gitHubHost = this._gitHubEndpointService.getEnterpriseHost() ?? 'github.com'; - const pullRequestReferences = parseGitHubPullRequestReferences(text, repository, gitHubHost) - .filter(reference => !repository || reference.owner.toLowerCase() === repository.owner.toLowerCase() && reference.repo.toLowerCase() === repository.repo.toLowerCase()); - if (issueReferences.length === 0 && pullRequestReferences.length === 0) { - return; - } - - const currentIssueUrls = currentState?.issueUrls ?? []; - const nextIssueUrls = [...currentIssueUrls]; - for (const reference of issueReferences) { - const url = toGitHubIssueUrl(reference); - if (!nextIssueUrls.includes(url)) { - nextIssueUrls.push(url); - } - } - - let nextState: ISessionGitHubState = issueReferences.length > 0 - ? { issueUrls: nextIssueUrls.slice(0, MAX_SESSION_ISSUE_REFERENCES) } - : {}; - for (let index = pullRequestReferences.length - 1; index >= 0; index--) { - const reference = pullRequestReferences[index]; - const url = toGitHubPullRequestUrl(reference, gitHubHost); - nextState = { - ...nextState, - ...withMostRecentReferencedSessionPullRequest({ ...currentState, ...nextState }, url) - }; - } - await this.setSessionGitHubState(sessionKey, nextState); - } - async refreshSessionGitState(sessionKey: string, workingDirectory: URI | undefined): Promise { const sessionState = this._stateManager.getSessionState(sessionKey); if (sessionState?.lifecycle === SessionLifecycle.Failed) { diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 2173b7a7f26..17e62dceebd 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -61,7 +61,7 @@ import { AgentServerToolHost } from './shared/agentServerToolHost.js'; import { type IChatContextSnapshot, type IRenameTitleResult, type ISessionCreationDefaults, type ISessionServerToolAccessor, validateRenameTitle } from './shared/sessionServerTools.js'; import { AGENT_HOST_TITLE_SOURCE_AGENT, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, persistSessionMetadata, persistSessionMetadataValues, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js'; import { type IArtifactServerToolAccessor } from './shared/artifactServerTools.js'; -import { parseSessionArtifacts, stringifySessionArtifacts, withSessionArtifacts } from '../common/sessionArtifacts.js'; +import { parseSessionArtifacts, stringifySessionArtifacts, withSessionArtifacts, type ISessionArtifact } from '../common/sessionArtifacts.js'; import { buildWorktreeFailureNotification, IAgentHostWorktreeIsolation, WORKTREE_META_REPOSITORY_ROOT, worktreeProjectFromRepositoryRoot } from './shared/worktreeIsolation.js'; import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; @@ -1140,6 +1140,21 @@ export class AgentService extends Disposable implements IAgentService { return this._configurationService.getRootValue(platformRootSchema, AgentHostArtifactToolsConfigKey) === true; } + /** + * Reads a session's persisted artifacts and references, warning when any are + * lost. A corrupt row would otherwise empty a session's artifacts pill with + * no trace of why the agent's recorded work disappeared. + */ + private _readPersistedArtifacts(value: string | undefined, session: string, logPrefix: string): readonly ISessionArtifact[] { + const { artifacts, error, dropped } = parseSessionArtifacts(value); + if (error) { + this._logService.warn(`${logPrefix} Failed to parse artifacts for ${session}: ${toErrorMessage(error)}`); + } else if (dropped > 0) { + this._logService.warn(`${logPrefix} Dropped ${dropped} malformed artifact(s) for ${session}`); + } + return artifacts; + } + private _getServerToolCreationDefaults(source: URI): ISessionCreationDefaults | undefined { const session = this._stateManager.getSessionState(source.toString()); if (!session) { @@ -2058,7 +2073,7 @@ export class AgentService extends Disposable implements IAgentService { if (multiRoot) { updated = { ...updated, _meta: withSessionMultiRootMetadata(updated._meta, multiRoot) }; } - const artifacts = parseSessionArtifacts(m[SESSION_ARTIFACTS_KEY]); + const artifacts = this._readPersistedArtifacts(m[SESSION_ARTIFACTS_KEY], sessionStr, '[AgentService][listSessions]'); if (artifacts.length > 0) { updated = { ...updated, _meta: withSessionArtifacts(updated._meta, artifacts) }; } @@ -5090,7 +5105,7 @@ export class AgentService extends Disposable implements IAgentService { sessionMetadata = withSessionCreationReference(sessionMetadata, creationReference); } sessionMetadata = withSessionMultiRootMetadata(sessionMetadata, parseSessionMultiRootMetadata(m[SESSION_META_MULTI_ROOT_KEY])); - sessionMetadata = withSessionArtifacts(sessionMetadata, parseSessionArtifacts(m[SESSION_ARTIFACTS_KEY])); + sessionMetadata = withSessionArtifacts(sessionMetadata, this._readPersistedArtifacts(m[SESSION_ARTIFACTS_KEY], sessionStr, '[AgentService]')); sessionMetadata = withSessionFolderPickerDecision(sessionMetadata, parseSessionFolderPickerDecision(m[SESSION_META_FOLDER_PICKER_KEY])); if (m.configValues) { diff --git a/src/vs/platform/agentHost/node/chatContributions/githubReferences/githubReferencesContribution.ts b/src/vs/platform/agentHost/node/chatContributions/githubReferences/githubReferencesContribution.ts index 3a7030833c5..b732aa66136 100644 --- a/src/vs/platform/agentHost/node/chatContributions/githubReferences/githubReferencesContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/githubReferences/githubReferencesContribution.ts @@ -6,10 +6,10 @@ import { Disposable } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; import { IAgentHostGitStateService } from '../../../common/agentHostGitStateService.js'; -import { type IAgentHostChatContribution, type IAgentHostChatContributionContext, type IOutgoingTurn, type ITurnEnd } from '../../../common/agentHostChatContributionsService.js'; +import { type IAgentHostChatContribution, type IAgentHostChatContributionContext, type ITurnEnd } from '../../../common/agentHostChatContributionsService.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; -/** Attaches GitHub references from outgoing messages and the current pull request after success. */ +/** Attaches the session's current pull request after a successful turn. */ export class GitHubReferencesContribution extends Disposable implements IAgentHostChatContribution { static readonly id = 'githubReferences'; @@ -23,11 +23,6 @@ export class GitHubReferencesContribution extends Disposable implements IAgentHo super(); } - onOutgoingTurn(turn: IOutgoingTurn): undefined { - void this._gitStateService.attachSessionGitHubReferences(turn.session, turn.message.text); - return undefined; - } - onTurnEnd(turn: ITurnEnd): void { if (turn.reason.kind === 'success') { const workingDirectory = this._stateManager.getSessionState(turn.session)?.workingDirectories?.[0]; diff --git a/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts b/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts index 5e17585ece8..f78973b910c 100644 --- a/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts +++ b/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts @@ -59,6 +59,15 @@ export interface IServerToolExecutionContext { export interface IServerToolGroup { /** Tool definitions this group advertises on the session's `serverTools`. */ readonly definitions: readonly IAgentServerToolDefinition[]; + /** + * Names this group's tools were previously advertised under, mapped to the + * name that replaced them. A renamed tool has to keep answering to its old + * name: restored history and prompts written against the old name would + * otherwise fail to route and lose their dedicated display. Legacy names are + * never advertised, and the host translates them before dispatching, so a + * group only ever sees its current names. + */ + readonly legacyToolNames?: ReadonlyMap; /** Whether a contributed tool is currently enabled for advertisement and execution. */ isEnabled(toolName: string): boolean; /** @@ -117,7 +126,10 @@ export interface IServerToolGroup { */ export class AgentServerToolHost implements IAgentServerToolHost { + /** Every name the host answers to — current and legacy — and its owning group. */ private readonly _groupByToolName = new Map(); + /** Legacy names mapped to the current name that replaced them. */ + private readonly _currentToolNames = new Map(); constructor( private readonly _stateManager: AgentHostStateManager, @@ -131,6 +143,22 @@ export class AgentServerToolHost implements IAgentServerToolHost { this._groupByToolName.set(def.name, group); } } + // Registered after every current name, so a legacy name can never shadow + // a tool that is actually advertised under it. + for (const group of this._groups) { + for (const [legacyName, currentName] of group.legacyToolNames ?? []) { + if (this._groupByToolName.has(legacyName)) { + continue; + } + this._groupByToolName.set(legacyName, group); + this._currentToolNames.set(legacyName, currentName); + } + } + } + + /** The name a group knows a tool by, translating a legacy name if needed. */ + private _currentToolName(toolName: string): string { + return this._currentToolNames.get(toolName) ?? toolName; } get definitions(): readonly IAgentServerToolDefinition[] { @@ -160,16 +188,18 @@ export class AgentServerToolHost implements IAgentServerToolHost { canRequireConfirmation(toolName: string): boolean { const group = this._groupByToolName.get(toolName); - return group?.isEnabled(toolName) === true && (group.canRequireConfirmation?.(toolName) ?? false); + const name = this._currentToolName(toolName); + return group?.isEnabled(name) === true && (group.canRequireConfirmation?.(name) ?? false); } requiresConfirmation(chatUri: URI, toolName: string): boolean { const group = this._groupByToolName.get(toolName); - if (group && !this._isEnabledForSession(group, chatUri, toolName)) { + const name = this._currentToolName(toolName); + if (group && !this._isEnabledForSession(group, chatUri, name)) { return false; } - return group?.requiresConfirmation?.(this._stateManager, this._executionContext(chatUri), toolName) - ?? group?.canRequireConfirmation?.(toolName) + return group?.requiresConfirmation?.(this._stateManager, this._executionContext(chatUri), name) + ?? group?.canRequireConfirmation?.(name) ?? false; } @@ -178,10 +208,11 @@ export class AgentServerToolHost implements IAgentServerToolHost { if (!group) { throw new Error(`Unknown server tool: ${toolName}`); } - if (!this._isEnabledForSession(group, chatUri, toolName)) { + const name = this._currentToolName(toolName); + if (!this._isEnabledForSession(group, chatUri, name)) { throw new Error(`Server tool "${toolName}" is disabled.`); } - return group.execute(this._stateManager, this._executionContext(chatUri), toolName, rawArgs); + return group.execute(this._stateManager, this._executionContext(chatUri), name, rawArgs); } private _executionContext(chatUri: URI): IServerToolExecutionContext { diff --git a/src/vs/platform/agentHost/node/shared/artifactServerTools.ts b/src/vs/platform/agentHost/node/shared/artifactServerTools.ts index 70ec357ff4c..bd29300d3c3 100644 --- a/src/vs/platform/agentHost/node/shared/artifactServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/artifactServerTools.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { generateUuid } from '../../../../base/common/uuid.js'; -import { ArtifactServerToolName } from '../../common/serverToolNames.js'; +import { ArtifactServerToolName, LEGACY_ARTIFACT_SERVER_TOOL_NAMES } from '../../common/serverToolNames.js'; import { parseSessionArtifactInput, SessionArtifactCollection } from '../../common/sessionArtifactCollection.js'; import { readSessionArtifacts, SESSION_ARTIFACT_TYPES, SessionArtifactType, withSessionArtifacts, type ISessionArtifact } from '../../common/sessionArtifacts.js'; import { parseRequiredSessionUriFromChatUri, type ToolDefinition } from '../../common/state/sessionState.js'; @@ -17,21 +17,24 @@ const addArtifactInputSchema: ToolDefinition['inputSchema'] = { type: { type: 'string', enum: [...SESSION_ARTIFACT_TYPES], - description: 'The kind of artifact. Use `resource` only when no other kind applies.', + description: 'The kind of artifact or reference. Use `resource` only when no other kind applies.', }, label: { type: 'string', description: 'Short label shown to the user.' }, + isArtifact: { + type: 'boolean', + description: 'Required. `true` for an artifact — something this session produced, such as a pull request or issue it opened, a plan file it wrote outside the workspace, or another side effect of its work. `false` for a reference — something it did not produce but the user should look at, such as the pull request or commit that introduced a bug, or a website that matters for the task.', + }, link: { type: 'string', description: 'URL of the pull request, issue, commit or website. Required for those kinds.' }, uri: { type: 'string', description: 'URI of the file or resource. Required for the `file` and `resource` kinds.' }, commitHash: { type: 'string', description: 'The commit hash. Required for the `commit` kind.' }, - createdByThisSession: { type: 'boolean', description: 'Required for the `pullRequest` kind: `true` when this session created the pull request, `false` when it only references an existing one.' }, }, - required: ['type', 'label'], + required: ['type', 'label', 'isArtifact'], }; const removeArtifactInputSchema: ToolDefinition['inputSchema'] = { type: 'object', properties: { - id: { type: 'string', description: 'The artifact id returned by `add_artifact` or `list_artifacts`.' }, + id: { type: 'string', description: `The id returned by \`${ArtifactServerToolName.AddArtifactOrReference}\` or \`${ArtifactServerToolName.ListArtifactsAndReferences}\`.` }, }, required: ['id'], }; @@ -43,23 +46,23 @@ const listArtifactsInputSchema: ToolDefinition['inputSchema'] = { export const artifactServerToolDefinitions: ToolDefinition[] = [ { - name: ArtifactServerToolName.AddArtifact, - title: 'Add Artifact', - description: 'Record something the user will want to open — a pull request, issue, commit found while investigating or answering a question, website, file or other resource — so it is surfaced next to the chat input. Do not record commits you create unless the user explicitly asks you to add them as artifacts.', + name: ArtifactServerToolName.AddArtifactOrReference, + title: 'Add Artifact or Reference', + description: 'Record an artifact or a reference so it is surfaced next to the chat input. An artifact is something this session produced that is not just an ordinary workspace edit: a pull request or issue it opened, a plan or report file it wrote outside the workspace, or another side effect of its work. A reference is something the session did not produce but the user should look at because of this task: the pull request or commit that introduced a bug, an issue it investigated, or a website worth reading. Set `isArtifact` accordingly. Do not record routine files you merely edited.', inputSchema: addArtifactInputSchema, annotations: { readOnlyHint: false }, }, { - name: ArtifactServerToolName.RemoveArtifact, - title: 'Remove Artifact', - description: 'Remove an artifact from this session by id.', + name: ArtifactServerToolName.RemoveArtifactOrReference, + title: 'Remove Artifact or Reference', + description: 'Remove an artifact or reference from this session by id.', inputSchema: removeArtifactInputSchema, annotations: { readOnlyHint: false, destructiveHint: true }, }, { - name: ArtifactServerToolName.ListArtifacts, - title: 'List Artifacts', - description: 'List the artifacts recorded on this session, with their ids.', + name: ArtifactServerToolName.ListArtifactsAndReferences, + title: 'List Artifacts and References', + description: 'List the artifacts and references recorded on this session, with their ids.', inputSchema: listArtifactsInputSchema, annotations: { readOnlyHint: true }, }, @@ -69,19 +72,27 @@ export const artifactServerToolDefinitions: ToolDefinition[] = [ export interface IArtifactServerToolAccessor { /** Whether the artifact tools are advertised and executable. */ readonly isEnabled: () => boolean; - /** Persists a session's artifacts so they survive a host restart. */ + /** Persists a session's artifacts and references so they survive a host restart. */ readonly persist: (session: string, artifacts: readonly ISessionArtifact[]) => void; } +/** The noun an entry is described by, so every message names what it acted on. */ +function entryNoun(isArtifact: boolean): string { + return isArtifact ? 'artifact' : 'reference'; +} + +const REMOVED_ARTIFACT_MESSAGE = 'Removed artifact'; +const REMOVED_REFERENCE_MESSAGE = 'Removed reference'; + function describeArtifact(artifact: ISessionArtifact): string { const value = artifact.link ?? artifact.uri ?? artifact.commitHash ?? ''; - return `${artifact.id} (${artifact.type}) ${artifact.label}${value ? ` — ${value}` : ''}`; + return `${artifact.id} (${artifact.type}, ${entryNoun(artifact.isArtifact)}) ${artifact.label}${value ? ` — ${value}` : ''}`; } /** - * Reads, mutates and republishes the artifacts of the session that owns the - * executing chat. The artifacts live on the session's `_meta` bag, so a change - * reaches subscribed clients through the regular action envelope. + * Reads, mutates and republishes the artifacts and references of the session + * that owns the executing chat. They live on the session's `_meta` bag, so a + * change reaches subscribed clients through the regular action envelope. */ class SessionArtifacts { @@ -108,21 +119,38 @@ class SessionArtifacts { export function createArtifactServerToolGroup(accessor?: IArtifactServerToolAccessor): IServerToolGroup { return { definitions: artifactServerToolDefinitions, + legacyToolNames: LEGACY_ARTIFACT_SERVER_TOOL_NAMES, isEnabled(): boolean { return accessor?.isEnabled() === true; }, - getDisplay(toolName, args): IServerToolDisplay | undefined { + getDisplay(toolName, args, result): IServerToolDisplay | undefined { switch (toolName) { - case ArtifactServerToolName.AddArtifact: { - const label = (args as { label?: unknown } | undefined)?.label; - return typeof label === 'string' && label.length > 0 - ? { displayName: 'Add Artifact', invocationMessage: `Add artifact "${label}"`, pastTenseMessage: `Added artifact "${label}"` } - : { displayName: 'Add Artifact', invocationMessage: 'Add artifact', pastTenseMessage: 'Added artifact' }; + case ArtifactServerToolName.AddArtifactOrReference: { + const { label, isArtifact } = (args ?? {}) as { label?: unknown; isArtifact?: unknown }; + // The flag is only trusted for display when the agent actually sent + // a boolean; `execute` rejects anything else. + const noun = typeof isArtifact === 'boolean' ? entryNoun(isArtifact) : 'artifact or reference'; + const suffix = typeof label === 'string' && label.length > 0 ? ` "${label}"` : ''; + return { + displayName: typeof isArtifact === 'boolean' ? (isArtifact ? 'Add Artifact' : 'Add Reference') : 'Add Artifact or Reference', + invocationMessage: `Add ${noun}${suffix}`, + pastTenseMessage: `Added ${noun}${suffix}`, + }; } - case ArtifactServerToolName.RemoveArtifact: - return { displayName: 'Remove Artifact', invocationMessage: 'Remove artifact', pastTenseMessage: 'Removed artifact' }; - case ArtifactServerToolName.ListArtifacts: - return { displayName: 'List Artifacts', invocationMessage: 'List artifacts', pastTenseMessage: 'Listed artifacts' }; + case ArtifactServerToolName.RemoveArtifactOrReference: { + // Only the result says whether an artifact or a reference was removed. + const text = result?.text ?? ''; + const pastTenseMessage = text.startsWith(REMOVED_REFERENCE_MESSAGE) + ? REMOVED_REFERENCE_MESSAGE + : text.startsWith(REMOVED_ARTIFACT_MESSAGE) ? REMOVED_ARTIFACT_MESSAGE : undefined; + return { + displayName: 'Remove Artifact or Reference', + invocationMessage: 'Remove artifact or reference', + ...(pastTenseMessage ? { pastTenseMessage } : {}), + }; + } + case ArtifactServerToolName.ListArtifactsAndReferences: + return { displayName: 'List Artifacts and References', invocationMessage: 'List artifacts and references', pastTenseMessage: 'Listed artifacts and references' }; default: return undefined; } @@ -134,31 +162,32 @@ export function createArtifactServerToolGroup(accessor?: IArtifactServerToolAcce const artifacts = new SessionArtifacts(stateManager, context); switch (toolName) { - case ArtifactServerToolName.AddArtifact: { - const input = parseSessionArtifactInput(rawArgs, ArtifactServerToolName.AddArtifact); + case ArtifactServerToolName.AddArtifactOrReference: { + const input = parseSessionArtifactInput(rawArgs, ArtifactServerToolName.AddArtifactOrReference); const result = artifacts.read().add(input, generateUuid); if (!result.added) { - return `Artifact already recorded: ${describeArtifact(result.artifact)}`; + return `Already recorded: ${describeArtifact(result.artifact)}`; } artifacts.write(result.artifacts, accessor); - return `Added artifact: ${describeArtifact(result.artifact)}`; + return `Added ${entryNoun(result.artifact.isArtifact)}: ${describeArtifact(result.artifact)}`; } - case ArtifactServerToolName.RemoveArtifact: { + case ArtifactServerToolName.RemoveArtifactOrReference: { const id = (rawArgs as { id?: unknown } | undefined)?.id; if (typeof id !== 'string' || id.length === 0) { - throw new Error(`Invalid ${ArtifactServerToolName.RemoveArtifact} input: id must be a non-empty string.`); + throw new Error(`Invalid ${ArtifactServerToolName.RemoveArtifactOrReference} input: id must be a non-empty string.`); } const result = artifacts.read().remove(id); if (!result.removed) { - return `No artifact with id ${id}.`; + return `No artifact or reference with id ${id}.`; } artifacts.write(result.artifacts, accessor); - return `Removed artifact: ${describeArtifact(result.removed)}`; + const message = result.removed.isArtifact ? REMOVED_ARTIFACT_MESSAGE : REMOVED_REFERENCE_MESSAGE; + return `${message}: ${describeArtifact(result.removed)}`; } - case ArtifactServerToolName.ListArtifacts: { + case ArtifactServerToolName.ListArtifactsAndReferences: { const current = artifacts.read().artifacts; return current.length === 0 - ? 'No artifacts recorded for this session.' + ? 'No artifacts or references recorded for this session.' : current.map(describeArtifact).join('\n'); } default: @@ -172,4 +201,4 @@ export function createArtifactServerToolGroup(accessor?: IArtifactServerToolAcce * The instruction appended to every agent's host instructions while the * artifact tools are enabled. */ -export const ARTIFACT_TOOLS_INSTRUCTION = `When you produce something the user will want to open — a pull request, an issue, a website, a plan file or another resource — or find a notable commit worth showing the user while investigating or answering a question, record it once with \`${ArtifactServerToolName.AddArtifact}\` (types: ${SESSION_ARTIFACT_TYPES.join(', ')}; use \`${SessionArtifactType.Resource}\` when nothing else fits). Do not record routine files you merely edited. Do not record commits you create unless the user explicitly asks you to add them as artifacts.`; +export const ARTIFACT_TOOLS_INSTRUCTION = `Record the notable results of your work with \`${ArtifactServerToolName.AddArtifactOrReference}\` (types: ${SESSION_ARTIFACT_TYPES.join(', ')}; use \`${SessionArtifactType.Resource}\` when nothing else fits) so they are surfaced next to the chat input. Pass \`isArtifact: true\` for an artifact — something this session produced beyond ordinary workspace edits, such as a pull request or issue you opened, a plan or report file you wrote outside the workspace, or another side effect of your work. Pass \`isArtifact: false\` for a reference — something you did not produce but the user should look at because of this task, such as the pull request or commit that introduced a bug, an issue you investigated, or a website worth reading. Record each one once, and do not record routine files you merely edited or commits you create unless the user asks for them.`; diff --git a/src/vs/platform/agentHost/node/shared/serverToolGroups.ts b/src/vs/platform/agentHost/node/shared/serverToolGroups.ts index 706f9a3a44d..d0a4f156d70 100644 --- a/src/vs/platform/agentHost/node/shared/serverToolGroups.ts +++ b/src/vs/platform/agentHost/node/shared/serverToolGroups.ts @@ -75,5 +75,17 @@ export function getServerToolDisplay(toolName: string, args: unknown, result?: I } } } + // Only once no advertised tool matched: a restored call made under a name + // that has since been renamed still gets the display of its replacement. + for (const group of serverToolGroupsForDisplay) { + if (!group.getDisplay) { + continue; + } + for (const [legacyName, currentName] of group.legacyToolNames ?? []) { + if (matchesServerToolName(toolName, legacyName)) { + return group.getDisplay(currentName, args, result); + } + } + } return undefined; } diff --git a/src/vs/platform/agentHost/test/common/githubIssueReferences.test.ts b/src/vs/platform/agentHost/test/common/githubIssueReferences.test.ts deleted file mode 100644 index 2022520cafb..00000000000 --- a/src/vs/platform/agentHost/test/common/githubIssueReferences.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { parseGitHubIssueReferences } from '../../common/githubIssueReferences.js'; - -suite('parseGitHubIssueReferences', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('detects issue URLs and owner/repo shorthand, ignores everything else', () => { - const text = [ - 'Fix https://github.com/microsoft/vscode/issues/123 first.', - 'Related: microsoft/vscode#456 and octo-org/my.repo#7.', - 'Also see https://www.github.com/microsoft/vscode/issues/123#issuecomment-99 (dupe).', - 'Not an issue: #789, https://github.com/microsoft/vscode/pull/321, https://gitlab.com/o/r/issues/5.', - ].join('\n'); - - assert.deepStrictEqual(parseGitHubIssueReferences(text), [ - { owner: 'microsoft', repo: 'vscode', number: 123 }, - { owner: 'microsoft', repo: 'vscode', number: 456 }, - { owner: 'octo-org', repo: 'my.repo', number: 7 }, - ]); - }); - - test('returns nothing for text without references', () => { - assert.deepStrictEqual(parseGitHubIssueReferences('Please refactor the parser and add tests.'), []); - }); -}); diff --git a/src/vs/platform/agentHost/test/common/githubPullRequestReferences.test.ts b/src/vs/platform/agentHost/test/common/githubPullRequestReferences.test.ts deleted file mode 100644 index d01436bd5b2..00000000000 --- a/src/vs/platform/agentHost/test/common/githubPullRequestReferences.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { parseGitHubPullRequestReferences } from '../../common/githubPullRequestReferences.js'; - -suite('GitHub pull request references', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - test('extracts URLs and repository-scoped shorthand without duplicates', () => { - assert.deepStrictEqual(parseGitHubPullRequestReferences( - 'Compare pull request #43 with https://github.com/microsoft/vscode/pull/42, then check PR #42.', - { owner: 'microsoft', repo: 'vscode' } - ), [ - { owner: 'microsoft', repo: 'vscode', number: 43 }, - { owner: 'microsoft', repo: 'vscode', number: 42 }, - ]); - }); - - test('ignores shorthand without repository context', () => { - assert.deepStrictEqual(parseGitHubPullRequestReferences('Check PR #42, issue #7, and #9.'), []); - }); - - test('uses the configured GitHub Enterprise host', () => { - assert.deepStrictEqual(parseGitHubPullRequestReferences( - 'Compare https://github.com/o/r/pull/1 with https://ghe.example.com/o/r/pull/2 and PR #3.', - { owner: 'o', repo: 'r' }, - 'ghe.example.com' - ), [ - { owner: 'o', repo: 'r', number: 2 }, - { owner: 'o', repo: 'r', number: 3 }, - ]); - }); -}); diff --git a/src/vs/platform/agentHost/test/common/sessionArtifacts.test.ts b/src/vs/platform/agentHost/test/common/sessionArtifacts.test.ts index 192d86b305b..1f93f7d53dd 100644 --- a/src/vs/platform/agentHost/test/common/sessionArtifacts.test.ts +++ b/src/vs/platform/agentHost/test/common/sessionArtifacts.test.ts @@ -13,25 +13,26 @@ suite('Session Artifacts', () => { let nextId = 0; const createId = () => `id-${++nextId}`; + const TOOL = 'add_artifact_or_reference'; setup(() => { nextId = 0; }); - test('adds typed artifacts and stamps isGitHub for pull requests and issues', () => { + test('adds typed artifacts and references and stamps isGitHub for pull requests and issues', () => { const collection = new SessionArtifactCollection(); - const pullRequest = collection.add(parseSessionArtifactInput({ type: 'pullRequest', label: 'Fix login', link: 'https://github.com/microsoft/vscode/pull/1', createdByThisSession: true }, 'add_artifact'), createId); - const issue = new SessionArtifactCollection(pullRequest.artifacts).add(parseSessionArtifactInput({ type: 'issue', label: 'Crash', link: 'https://example.com/issues/2' }, 'add_artifact'), createId); - const commit = new SessionArtifactCollection(issue.artifacts).add(parseSessionArtifactInput({ type: 'commit', label: 'Refactor', link: 'https://github.com/microsoft/vscode/commit/abc', commitHash: 'abc123' }, 'add_artifact'), createId); + const pullRequest = collection.add(parseSessionArtifactInput({ type: 'pullRequest', label: 'Fix login', link: 'https://github.com/microsoft/vscode/pull/1', isArtifact: true }, TOOL), createId); + const issue = new SessionArtifactCollection(pullRequest.artifacts).add(parseSessionArtifactInput({ type: 'issue', label: 'Crash', link: 'https://example.com/issues/2', isArtifact: false }, TOOL), createId); + const commit = new SessionArtifactCollection(issue.artifacts).add(parseSessionArtifactInput({ type: 'commit', label: 'Refactor', link: 'https://github.com/microsoft/vscode/commit/abc', commitHash: 'abc123', isArtifact: false }, TOOL), createId); assert.deepStrictEqual(commit.artifacts, [ - { id: 'id-1', type: SessionArtifactType.PullRequest, label: 'Fix login', link: 'https://github.com/microsoft/vscode/pull/1', isGitHub: true, createdByThisSession: true }, - { id: 'id-2', type: SessionArtifactType.Issue, label: 'Crash', link: 'https://example.com/issues/2', isGitHub: false }, - { id: 'id-3', type: SessionArtifactType.Commit, label: 'Refactor', link: 'https://github.com/microsoft/vscode/commit/abc', commitHash: 'abc123' }, + { id: 'id-1', type: SessionArtifactType.PullRequest, label: 'Fix login', isArtifact: true, link: 'https://github.com/microsoft/vscode/pull/1', isGitHub: true }, + { id: 'id-2', type: SessionArtifactType.Issue, label: 'Crash', isArtifact: false, link: 'https://example.com/issues/2', isGitHub: false }, + { id: 'id-3', type: SessionArtifactType.Commit, label: 'Refactor', isArtifact: false, link: 'https://github.com/microsoft/vscode/commit/abc', commitHash: 'abc123' }, ]); }); test('rejects a duplicate value and returns the existing artifact', () => { - const first = new SessionArtifactCollection().add(parseSessionArtifactInput({ type: 'file', label: 'Plan', uri: 'file:///repo/plan.md' }, 'add_artifact'), createId); - const duplicate = new SessionArtifactCollection(first.artifacts).add(parseSessionArtifactInput({ type: 'file', label: 'Plan again', uri: 'file:///repo/plan.md' }, 'add_artifact'), createId); + const first = new SessionArtifactCollection().add(parseSessionArtifactInput({ type: 'file', label: 'Plan', uri: 'file:///repo/plan.md', isArtifact: true }, TOOL), createId); + const duplicate = new SessionArtifactCollection(first.artifacts).add(parseSessionArtifactInput({ type: 'file', label: 'Plan again', uri: 'file:///repo/plan.md', isArtifact: true }, TOOL), createId); assert.deepStrictEqual({ added: duplicate.added, @@ -45,7 +46,7 @@ suite('Session Artifacts', () => { }); test('removes by id and reports unknown ids', () => { - const added = new SessionArtifactCollection().add(parseSessionArtifactInput({ type: 'website', label: 'Docs', link: 'https://example.com' }, 'add_artifact'), createId); + const added = new SessionArtifactCollection().add(parseSessionArtifactInput({ type: 'website', label: 'Docs', link: 'https://example.com', isArtifact: false }, TOOL), createId); const collection = new SessionArtifactCollection(added.artifacts); assert.deepStrictEqual({ @@ -58,39 +59,96 @@ suite('Session Artifacts', () => { }); test('validates required fields per type', () => { - assert.throws(() => parseSessionArtifactInput({ type: 'pullRequest', label: 'No link' }, 'add_artifact'), /link/); - assert.throws(() => parseSessionArtifactInput({ type: 'pullRequest', label: 'No flag', link: 'https://github.com/microsoft/vscode/pull/1' }, 'add_artifact'), /createdByThisSession/); - assert.throws(() => parseSessionArtifactInput({ type: 'file', label: 'No uri' }, 'add_artifact'), /uri/); - assert.throws(() => parseSessionArtifactInput({ type: 'commit', label: 'No hash', link: 'https://example.com' }, 'add_artifact'), /commitHash/); - assert.throws(() => parseSessionArtifactInput({ type: 'unknown', label: 'Bad' }, 'add_artifact'), /type/); + assert.throws(() => parseSessionArtifactInput({ type: 'pullRequest', label: 'No link', isArtifact: true }, TOOL), /link/); + assert.throws(() => parseSessionArtifactInput({ type: 'pullRequest', label: 'No flag', link: 'https://github.com/microsoft/vscode/pull/1' }, TOOL), /isArtifact/); + assert.throws(() => parseSessionArtifactInput({ type: 'file', label: 'No uri', isArtifact: true }, TOOL), /uri/); + assert.throws(() => parseSessionArtifactInput({ type: 'commit', label: 'No hash', link: 'https://example.com', isArtifact: false }, TOOL), /commitHash/); + assert.throws(() => parseSessionArtifactInput({ type: 'unknown', label: 'Bad', isArtifact: true }, TOOL), /type/); + }); + + test('rejects a uri the client could not open, which would vanish from every pill', () => { + const parse = (uri: string) => () => parseSessionArtifactInput({ type: 'file', label: 'Plan', uri, isArtifact: true }, TOOL); + + assert.throws(parse('plan.md'), /absolute URI/); + assert.throws(parse('/repo/plan.md'), /absolute URI/); + assert.throws(parse('C:\\repo\\plan.md'), /absolute URI/); + // A scheme the URI grammar rejects: the client fails to parse it too. + assert.throws(parse('foo/bar:baz'), /absolute URI/); + assert.strictEqual(parseSessionArtifactInput({ type: 'file', label: 'Plan', uri: 'file:///repo/plan.md', isArtifact: true }, TOOL).uri, 'file:///repo/plan.md'); + // Validation is the client's own parse, so anything it opens is accepted — + // a leading digit is legal for `URI`, whose scheme grammar is the contract. + assert.strictEqual(parseSessionArtifactInput({ type: 'resource', label: 'Custom', uri: '1scheme:/x', isArtifact: true }, TOOL).uri, '1scheme:/x'); }); test('rejects links that are not http(s), since a link is opened externally', () => { - const parse = (link: string) => () => parseSessionArtifactInput({ type: 'website', label: 'Link', link }, 'add_artifact'); + const parse = (link: string) => () => parseSessionArtifactInput({ type: 'website', label: 'Link', link, isArtifact: false }, TOOL); assert.throws(parse('file:///etc/passwd'), /http\(s\)/); assert.throws(parse('vscode://extension/evil'), /http\(s\)/); assert.throws(parse('javascript:alert(1)'), /http\(s\)/); assert.throws(parse('/not/absolute'), /absolute http\(s\) URL/); - assert.strictEqual(parseSessionArtifactInput({ type: 'website', label: 'Docs', link: 'https://example.com/x' }, 'add_artifact').link, 'https://example.com/x'); + assert.strictEqual(parseSessionArtifactInput({ type: 'website', label: 'Docs', link: 'https://example.com/x', isArtifact: false }, TOOL).link, 'https://example.com/x'); }); test('round-trips artifacts through the meta bag and the session database', () => { - const added = new SessionArtifactCollection().add(parseSessionArtifactInput({ type: 'resource', label: 'Dashboard', uri: 'https://example.com/dash' }, 'add_artifact'), createId); + const added = new SessionArtifactCollection().add(parseSessionArtifactInput({ type: 'resource', label: 'Dashboard', uri: 'https://example.com/dash', isArtifact: true }, TOOL), createId); const meta = withSessionArtifacts({ other: 'kept' }, added.artifacts); assert.deepStrictEqual({ meta, fromMeta: readSessionArtifacts(meta), - fromStorage: parseSessionArtifacts(stringifySessionArtifacts(added.artifacts)), + fromStorage: parseSessionArtifacts(stringifySessionArtifacts(added.artifacts)).artifacts, cleared: withSessionArtifacts(meta, []), - corrupted: parseSessionArtifacts('not json'), }, { meta: { other: 'kept', 'agentHost/sessionArtifacts': added.artifacts }, fromMeta: added.artifacts, fromStorage: added.artifacts, cleared: { other: 'kept' }, - corrupted: [], + }); + }); + + test('reports what persisted state could not be read, rather than silently losing it', () => { + const valid = { id: 'id-1', type: SessionArtifactType.Website, label: 'Docs', isArtifact: true, link: 'https://example.com' }; + + assert.deepStrictEqual({ + corrupt: parseSessionArtifacts('not json').error !== undefined, + notAnArray: parseSessionArtifacts('{}').error !== undefined, + partial: parseSessionArtifacts(JSON.stringify([valid, { id: 'id-2' }, 'nonsense'])), + absent: parseSessionArtifacts(undefined), + }, { + corrupt: true, + notAnArray: true, + partial: { artifacts: [valid], dropped: 2 }, + absent: { artifacts: [], dropped: 0 }, + }); + }); + + test('reads entries recorded before references existed as artifacts', () => { + const legacy = [{ id: 'id-1', type: SessionArtifactType.PullRequest, label: 'Legacy', link: 'https://github.com/microsoft/vscode/pull/1', createdByThisSession: false }]; + + assert.deepStrictEqual(readSessionArtifacts({ 'agentHost/sessionArtifacts': legacy }), [ + { id: 'id-1', type: SessionArtifactType.PullRequest, label: 'Legacy', isArtifact: true, link: 'https://github.com/microsoft/vscode/pull/1' }, + ]); + }); + + test('rejects a malformed isArtifact rather than reading it as an artifact', () => { + const entry = (isArtifact: unknown) => ({ id: 'id-1', type: SessionArtifactType.Website, label: 'Docs', link: 'https://example.com', isArtifact }); + const read = (isArtifact: unknown) => readSessionArtifacts({ 'agentHost/sessionArtifacts': [entry(isArtifact)] }).map(artifact => artifact.isArtifact); + + assert.deepStrictEqual({ + trueFlag: read(true), + falseFlag: read(false), + stringFalse: read('false'), + nullFlag: read(null), + numberFlag: read(0), + }, { + trueFlag: [true], + falseFlag: [false], + // Only a boolean or an absent field is accepted, so these are dropped + // and counted as malformed rather than silently becoming artifacts. + stringFalse: [], + nullFlag: [], + numberFlag: [], }); }); diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index c757e4a3e28..48b13c83f36 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -418,7 +418,6 @@ export function createNoopGitStateService(): IAgentHostGitStateService { setSessionGitHubState: async (_sessionKey: string, _state: ISessionGitHubState) => { }, recordSessionMerge: async (_sessionKey: string, _commit: string) => { }, attachSessionGitHubPullRequest: async (_sessionKey: string, _workingDirectory?: URI) => { }, - attachSessionGitHubReferences: async (_sessionKey: string, _text: string) => { }, }; } diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts index caef5086731..a6b09a84c21 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts @@ -858,7 +858,6 @@ class TestGitStateService extends Disposable implements IAgentHostGitStateServic async setSessionGitHubState(_sessionKey: string, _state: ISessionGitHubState): Promise { } async recordSessionMerge(_sessionKey: string, _commit?: string): Promise { } async attachSessionGitHubPullRequest(_sessionKey: string): Promise { } - async attachSessionGitHubReferences(_sessionKey: string, _text: string): Promise { } fireGitHubStateChanged(sessionKey: string): void { this._onDidChangeSessionGitHubState.fire(sessionKey); diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts index e107af5192c..83b14c748b0 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts @@ -100,7 +100,6 @@ class TestGitStateService implements IAgentHostGitStateService { async recordSessionMerge(_sessionKey: string, _commit?: string): Promise { } async attachSessionGitHubPullRequest(_sessionKey: string): Promise { } - async attachSessionGitHubReferences(_sessionKey: string, _text: string): Promise { } } /** diff --git a/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts b/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts index 1837dfa5563..be4ad716ce7 100644 --- a/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts @@ -47,7 +47,6 @@ const nullGitStateService: IAgentHostGitStateService = { async setSessionGitHubState() { }, async recordSessionMerge() { }, async attachSessionGitHubPullRequest() { }, - async attachSessionGitHubReferences() { }, }; suite('AgentHostContributions', () => { diff --git a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts index a736679553c..92354f49343 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts @@ -10,7 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { NullLogService } from '../../../log/common/log.js'; import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js'; -import { getSessionRelatedPullRequestUrls, hasSessionPullRequestForBranch, readSessionGitHubState, readSessionGitState, readSessionSourceControlState, SESSION_META_GITHUB_KEY, SessionSourceControlOutcome, withInitialSessionPullRequest, withMostRecentRelatedSessionPullRequest, withMostRecentSessionPullRequest, withSessionGitHubState, withSessionGitState, SessionStatus, type ISessionGitHubState, type ISessionGitState, type SessionSummary } from '../../common/state/sessionState.js'; +import { getSessionRelatedPullRequestUrls, readSessionGitHubState, readSessionGitState, readSessionSourceControlState, SESSION_META_GITHUB_KEY, SessionSourceControlOutcome, withInitialSessionPullRequest, withMostRecentRelatedSessionPullRequest, withMostRecentSessionPullRequest, withSessionGitHubState, withSessionGitState, SessionStatus, type ISessionGitHubState, type ISessionGitState, type SessionSummary } from '../../common/state/sessionState.js'; import { META_GIT_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../../common/agentHostGitStateService.js'; import { AgentHostGitStateService } from '../../node/agentHostGitStateService.js'; import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; @@ -823,168 +823,6 @@ suite('AgentHostGitStateService', () => { }); }); - test('promotes a referenced baseline pull request', async () => { - const h = createHarness(); - const pullRequestUrl = 'https://github.com/microsoft/vscode/pull/1'; - seedSession(h.stateManager, { - workingDirectory: WORKING_DIRECTORY, - gitHubState: { - owner: 'microsoft', - repo: 'vscode', - pullRequestUrls: [pullRequestUrl], - initialPullRequestUrls: [pullRequestUrl], - pullRequestBranchName: 'feature', - }, - isolation: 'folder', - }); - - await h.service.attachSessionGitHubReferences(SESSION, 'Please unblock PR #1. Ignore https://github.com/octo/repo/pull/9.'); - - const github = readSessionGitHubState(h.stateManager.getSessionState(SESSION)?._meta); - assert.deepStrictEqual({ - github, - related: [...getSessionRelatedPullRequestUrls(github)], - }, { - github: { - owner: 'microsoft', - repo: 'vscode', - pullRequestUrls: [pullRequestUrl], - initialPullRequestUrls: [pullRequestUrl], - associatedPullRequestUrls: [pullRequestUrl], - pullRequestBranchName: 'feature', - }, - related: [pullRequestUrl], - }); - }); - - test('promotes a referenced GitHub Enterprise baseline pull request', async () => { - const h = createHarness({ enterpriseUri: 'https://ghe.example.com' }); - const pullRequestUrl = 'https://ghe.example.com/microsoft/vscode/pull/1'; - seedSession(h.stateManager, { - workingDirectory: WORKING_DIRECTORY, - gitHubState: { - owner: 'microsoft', - repo: 'vscode', - pullRequestUrls: [pullRequestUrl], - initialPullRequestUrls: [pullRequestUrl], - pullRequestBranchName: 'feature', - }, - isolation: 'folder', - }); - - await h.service.attachSessionGitHubReferences(SESSION, 'Please unblock PR #1.'); - - const github = readSessionGitHubState(h.stateManager.getSessionState(SESSION)?._meta); - assert.deepStrictEqual({ - github, - related: [...getSessionRelatedPullRequestUrls(github)], - }, { - github: { - owner: 'microsoft', - repo: 'vscode', - pullRequestUrls: [pullRequestUrl], - initialPullRequestUrls: [pullRequestUrl], - associatedPullRequestUrls: [pullRequestUrl], - pullRequestBranchName: 'feature', - }, - related: [pullRequestUrl], - }); - }); - - test('records an unrelated PR mention without changing checkout PR state', async () => { - const h = createHarness(); - seedSession(h.stateManager, { - workingDirectory: WORKING_DIRECTORY, - gitHubState: { owner: 'microsoft', repo: 'vscode', initialPullRequestUrls: [] }, - isolation: 'folder', - }); - - await h.service.attachSessionGitHubReferences(SESSION, 'Compare this with PR #99.'); - - const github = readSessionGitHubState(h.stateManager.getSessionState(SESSION)?._meta); - assert.deepStrictEqual({ - github, - related: [...getSessionRelatedPullRequestUrls(github)], - hasCheckoutPullRequest: hasSessionPullRequestForBranch(github, 'feature'), - }, { - github: { - owner: 'microsoft', - repo: 'vscode', - initialPullRequestUrls: [], - associatedPullRequestUrls: ['https://github.com/microsoft/vscode/pull/99'], - }, - related: [], - hasCheckoutPullRequest: false, - }); - }); - - test('retains a full PR URL mentioned before repository discovery', async () => { - const h = createHarness(); - const pullRequestUrl = 'https://github.com/microsoft/vscode/pull/1'; - seedSession(h.stateManager, { workingDirectory: WORKING_DIRECTORY, isolation: 'folder' }); - - await h.service.attachSessionGitHubReferences(SESSION, `Please unblock ${pullRequestUrl}.`); - await h.service.setSessionGitHubState(SESSION, { - owner: 'microsoft', - repo: 'vscode', - pullRequestUrls: [pullRequestUrl], - initialPullRequestUrls: [pullRequestUrl], - }); - - const github = readSessionGitHubState(h.stateManager.getSessionState(SESSION)?._meta); - assert.deepStrictEqual({ - github, - related: [...getSessionRelatedPullRequestUrls(github)], - }, { - github: { - owner: 'microsoft', - repo: 'vscode', - pullRequestUrls: [pullRequestUrl], - initialPullRequestUrls: [pullRequestUrl], - associatedPullRequestUrls: [pullRequestUrl], - }, - related: [pullRequestUrl], - }); - }); - - test('preserves an explicit PR reference while its baseline lookup is in flight', async () => { - await runWithFakedTimers({ useFakeTimers: true }, async () => { - const pullRequestUrl = 'https://github.com/microsoft/vscode/pull/1'; - const gitState: ISessionGitState = { branchName: 'feature', baseBranchName: 'main' }; - const h = createHarness(); - seedSession(h.stateManager, { - workingDirectory: WORKING_DIRECTORY, - gitState, - gitHubState: { owner: 'microsoft', repo: 'vscode' }, - isolation: 'folder', - createdAt: 600_000, - }); - h.setGitResult(gitState); - h.setPullRequest('feature', { url: pullRequestUrl, number: 1, createdAt: 1_000 }); - h.setOnPullRequestLookup(async () => { - await h.service.attachSessionGitHubReferences(SESSION, 'Please unblock PR #1.'); - }); - - await h.service.attachSessionGitHubPullRequest(SESSION, URI.parse(WORKING_DIRECTORY)); - - const github = readSessionGitHubState(h.stateManager.getSessionState(SESSION)?._meta); - assert.deepStrictEqual({ - github, - related: [...getSessionRelatedPullRequestUrls(github)], - }, { - github: { - owner: 'microsoft', - repo: 'vscode', - pullRequestUrls: [pullRequestUrl], - initialPullRequestUrls: [pullRequestUrl], - associatedPullRequestUrls: [pullRequestUrl], - pullRequestBranchName: 'feature', - }, - related: [pullRequestUrl], - }); - }); - }); - test('round-trips an empty folder-session baseline through persisted metadata', () => { const persisted = JSON.parse(JSON.stringify({ initialPullRequestUrls: [] })); @@ -993,33 +831,6 @@ suite('AgentHostGitStateService', () => { }); }); - test('accumulates the GitHub issues referenced across user messages', async () => { - const h = createHarness(); - seedSession(h.stateManager, { workingDirectory: WORKING_DIRECTORY }); - - await h.service.attachSessionGitHubReferences(SESSION, 'Fix https://github.com/microsoft/vscode/issues/1 please'); - await h.service.attachSessionGitHubReferences(SESSION, 'Also microsoft/vscode#1 and octo/repo#2, but not #3'); - await h.service.attachSessionGitHubReferences(SESSION, 'Nothing to see here'); - - assert.deepStrictEqual({ - github: readSessionGitHubState(h.stateManager.getSessionState(SESSION)?._meta), - persistedGitHub: await h.db.getMetadata(META_GITHUB_STATE), - }, { - github: { - issueUrls: [ - 'https://github.com/microsoft/vscode/issues/1', - 'https://github.com/octo/repo/issues/2', - ] - }, - persistedGitHub: JSON.stringify({ - issueUrls: [ - 'https://github.com/microsoft/vscode/issues/1', - 'https://github.com/octo/repo/issues/2', - ] - }), - }); - }); - test('swallows git errors and fires no events', async () => { const h = createHarness(); seedSession(h.stateManager, { workingDirectory: WORKING_DIRECTORY }); @@ -1122,7 +933,6 @@ suite('AgentHostGitStateService', () => { h.setGitResult(gitState); h.setPullRequest('feature', { url: 'https://github.com/microsoft/vscode/pull/1', number: 1 }); h.setOnPullRequestLookup(async () => { - await h.service.attachSessionGitHubReferences(SESSION, 'See microsoft/vscode#42'); const currentState = readSessionGitHubState(h.stateManager.getSessionState(SESSION)?._meta); await h.service.setSessionGitHubState(SESSION, withMostRecentSessionPullRequest(currentState, 'https://github.com/microsoft/vscode/pull/2', 'feature-2')); }); @@ -1136,7 +946,6 @@ suite('AgentHostGitStateService', () => { 'https://github.com/microsoft/vscode/pull/1', 'https://github.com/microsoft/vscode/pull/2', ], - issueUrls: ['https://github.com/microsoft/vscode/issues/42'], pullRequestBranchName: 'feature', }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostMergeOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostMergeOperationProvider.test.ts index 9967f64b46e..a68050f696e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostMergeOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostMergeOperationProvider.test.ts @@ -34,7 +34,6 @@ const nullGitStateService = new class implements IAgentHostGitStateService { async setSessionGitHubState(_sessionKey: string, _state: ISessionGitHubState): Promise { } async recordSessionMerge(): Promise { } async attachSessionGitHubPullRequest(): Promise { } - async attachSessionGitHubReferences(): Promise { } }; suite('AgentHostMergeOperationContribution', () => { diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts index 776222d342b..b63e514b158 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts @@ -24,7 +24,6 @@ const nullGitStateService = new class implements IAgentHostGitStateService { async setSessionGitHubState(): Promise { } async recordSessionMerge(): Promise { } async attachSessionGitHubPullRequest(): Promise { } - async attachSessionGitHubReferences(): Promise { } }; const githubBranchWithUncommittedChanges: ISessionGitState = { diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 5a5d1008554..de138011244 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -119,15 +119,6 @@ class NoopGitStateService implements IAgentHostGitStateService { async setSessionGitHubState(_sessionKey: string, _state: ISessionGitHubState): Promise { } async recordSessionMerge(_sessionKey: string, _commit: string): Promise { } async attachSessionGitHubPullRequest(_sessionKey: string, _workingDirectory?: URI): Promise { } - async attachSessionGitHubReferences(_sessionKey: string, _text: string): Promise { } -} - -class RecordingGitStateService extends NoopGitStateService { - readonly attachedGitHubReferences: { session: string; text: string }[] = []; - - override async attachSessionGitHubReferences(session: string, text: string): Promise { - this.attachedGitHubReferences.push({ session, text }); - } } class NoopWorktreeIsolation extends NullAgentHostWorktreeIsolation { } @@ -1270,36 +1261,6 @@ suite('AgentSideEffects', () => { assert.ok(errorAction, 'should dispatch a chat error for a read-only chat'); assert.deepStrictEqual(agent.sendMessageCalls, []); }); - - test('does not attach GitHub references for read-only or archived messages', () => { - setupSession(); - const gitStateService = new RecordingGitStateService(); - const referenceSideEffects = createTestSideEffects(disposables, stateManager, { - getAgent: () => agent, - agents: agentList, - sessionDataService: createNullSessionDataService(), - hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, - gitStateService, - }); - const readOnlyChat = buildChatUri(sessionUri, 'peer-ro'); - stateManager.addChat(sessionUri.toString(), readOnlyChat, { interactivity: ChatInteractivity.ReadOnly }); - - referenceSideEffects.handleAction(readOnlyChat, { - type: ActionType.ChatTurnStarted, - startedAt: '2025-01-01T00:00:00.000Z', - turnId: 'read-only-turn', - message: { text: 'Fix microsoft/vscode#42', origin: { kind: MessageKind.User } }, - }); - stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionIsArchivedChanged, isArchived: true }); - referenceSideEffects.handleAction(defaultChatUri, { - type: ActionType.ChatTurnStarted, - startedAt: '2025-01-01T00:00:00.000Z', - turnId: 'archived-turn', - message: { text: 'Fix microsoft/vscode#43', origin: { kind: MessageKind.User } }, - }); - - assert.deepStrictEqual(gitStateService.attachedGitHubReferences, []); - }); }); // ---- handleAction: first-turn materialization failure --------------- @@ -3042,33 +3003,6 @@ suite('AgentSideEffects', () => { }); }); - test('attaches GitHub references when sending a queued message', async () => { - setupSession(); - const gitStateService = new RecordingGitStateService(); - const referenceSideEffects = createTestSideEffects(disposables, stateManager, { - getAgent: () => agent, - agents: agentList, - sessionDataService: createNullSessionDataService(), - hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, - gitStateService, - }); - const action = { - type: ActionType.ChatPendingMessageSet as const, - kind: PendingMessageKind.Queued, - id: 'q-github-reference', - message: { text: 'Fix microsoft/vscode#42', origin: { kind: MessageKind.User } }, - }; - stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); - referenceSideEffects.handleAction(defaultChatUri, action); - - await waitForSendMessageCalls(1); - - assert.deepStrictEqual(gitStateService.attachedGitHubReferences, [{ - session: sessionUri.toString(), - text: 'Fix microsoft/vscode#42', - }]); - }); - test('parses queued protocol attachment URI strings before passing them to the agent', async () => { setupSession(); const fileUri = URI.file('/workspace/queued.ts'); diff --git a/src/vs/platform/agentHost/test/node/artifactServerTools.test.ts b/src/vs/platform/agentHost/test/node/artifactServerTools.test.ts new file mode 100644 index 00000000000..2ff6902edad --- /dev/null +++ b/src/vs/platform/agentHost/test/node/artifactServerTools.test.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ArtifactServerToolName } from '../../common/serverToolNames.js'; +import { createArtifactServerToolGroup } from '../../node/shared/artifactServerTools.js'; +import { getServerToolDisplay } from '../../node/shared/serverToolGroups.js'; + +suite('Artifact Server Tools', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const group = createArtifactServerToolGroup(); + const display = (toolName: string, args: unknown, result?: { text: string; success: boolean }) => group.getDisplay?.(toolName, args, result); + + test('names what was recorded, from the isArtifact flag', () => { + assert.deepStrictEqual({ + artifact: display(ArtifactServerToolName.AddArtifactOrReference, { label: 'Fix login', isArtifact: true }), + reference: display(ArtifactServerToolName.AddArtifactOrReference, { label: 'Broken commit', isArtifact: false }), + unlabelled: display(ArtifactServerToolName.AddArtifactOrReference, { isArtifact: false }), + malformed: display(ArtifactServerToolName.AddArtifactOrReference, undefined), + }, { + artifact: { displayName: 'Add Artifact', invocationMessage: 'Add artifact "Fix login"', pastTenseMessage: 'Added artifact "Fix login"' }, + reference: { displayName: 'Add Reference', invocationMessage: 'Add reference "Broken commit"', pastTenseMessage: 'Added reference "Broken commit"' }, + unlabelled: { displayName: 'Add Reference', invocationMessage: 'Add reference', pastTenseMessage: 'Added reference' }, + malformed: { displayName: 'Add Artifact or Reference', invocationMessage: 'Add artifact or reference', pastTenseMessage: 'Added artifact or reference' }, + }); + }); + + test('names what a completed removal actually removed', () => { + const removed = (text: string) => display(ArtifactServerToolName.RemoveArtifactOrReference, { id: 'id-1' }, { text, success: true })?.pastTenseMessage; + + assert.deepStrictEqual({ + running: display(ArtifactServerToolName.RemoveArtifactOrReference, { id: 'id-1' }), + artifact: removed('Removed artifact: id-1 (file, artifact) Plan — file:///repo/plan.md'), + reference: removed('Removed reference: id-1 (website, reference) Docs — https://example.com'), + missing: removed('No artifact or reference with id id-1.'), + }, { + running: { displayName: 'Remove Artifact or Reference', invocationMessage: 'Remove artifact or reference' }, + artifact: 'Removed artifact', + reference: 'Removed reference', + missing: undefined, + }); + }); + + test('keeps the display of a call restored under a pre-rename tool name', () => { + const displayName = (toolName: string) => getServerToolDisplay(toolName, { label: 'Fix login', isArtifact: true })?.displayName; + + assert.deepStrictEqual({ + current: displayName(ArtifactServerToolName.AddArtifactOrReference), + legacyAdd: displayName('add_artifact'), + legacyRemove: getServerToolDisplay('remove_artifact', { id: 'id-1' })?.displayName, + legacyList: getServerToolDisplay('list_artifacts', undefined)?.displayName, + // Claude prefixes server tools on the wire; the suffix still resolves. + transportPrefixed: displayName('mcp__vscode__add_artifact'), + unknown: displayName('not_a_tool'), + }, { + current: 'Add Artifact', + legacyAdd: 'Add Artifact', + legacyRemove: 'Remove Artifact or Reference', + legacyList: 'List Artifacts and References', + transportPrefixed: 'Add Artifact', + unknown: undefined, + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/chatContributions.test.ts b/src/vs/platform/agentHost/test/node/chatContributions.test.ts index 217bf359d0a..764de8abad3 100644 --- a/src/vs/platform/agentHost/test/node/chatContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/chatContributions.test.ts @@ -36,7 +36,6 @@ import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.j import { AgentHostLocalTurns, IAgentHostLocalTurns } from '../../node/agentHostLocalTurns.js'; import { AgentHostTelemetryReporter, IAgentHostTelemetryReporter } from '../../node/agentHostTelemetryReporter.js'; import { AgentHostTurnTracker, IAgentHostTurnTracker } from '../../node/agentHostTurnTracker.js'; -import { GitHubReferencesContribution } from '../../node/chatContributions/githubReferences/githubReferencesContribution.js'; import { AgentHostLocalCommands, IAgentHostLocalCommands } from '../../node/localCommands/localChatCommand.js'; import { registerBuiltInChatContributions } from '../../node/chatContributions/builtInChatContributions.js'; import { QueueDrainContribution } from '../../node/chatContributions/queueDrain/queueDrainContribution.js'; @@ -99,7 +98,6 @@ class RecordingGitStateService implements IAgentHostGitStateService { declare readonly _serviceBrand: undefined; readonly onDidRefreshSessionGitState = Event.None; readonly onDidChangeSessionGitHubState = Event.None; - readonly attachedGitHubReferences: { session: string; text: string }[] = []; constructor(private readonly _observed: string[] | undefined) { } @@ -110,9 +108,6 @@ class RecordingGitStateService implements IAgentHostGitStateService { async attachSessionGitHubPullRequest(_sessionKey: string, _workingDirectory?: URI): Promise { this._observed?.push('githubReferences'); } - async attachSessionGitHubReferences(session: string, text: string): Promise { - this.attachedGitHubReferences.push({ session, text }); - } } class RecordingWorktreeIsolation extends NullAgentHostWorktreeIsolation { @@ -516,20 +511,6 @@ function createContributions(disposables: ReturnType): { service: IAgentHostChatContributions; gitStateService: RecordingGitStateService } { - const logService = new NullLogService(); - const stateManager = disposables.add(new AgentHostStateManager(logService)); - const gitStateService = new RecordingGitStateService(undefined); - const instantiationService = disposables.add(new InstantiationService(new ServiceCollection( - [ILogService, logService], - [IAgentHostStateManager, stateManager], - [IAgentHostGitStateService, gitStateService], - ), /*strict*/ true)); - const service: IAgentHostChatContributions = disposables.add(new AgentHostChatContributions(logService, instantiationService)); - disposables.add(service.registerContribution(GitHubReferencesContribution)); - return { service, gitStateService }; -} - function createSideChatContributions(disposables: ReturnType, inheritedTurnId?: string, selectionText?: string) { const logService = new NullLogService(); const stateManager = disposables.add(new AgentHostStateManager(logService)); @@ -1239,16 +1220,6 @@ suite('AgentHostChatContributions', () => { assert.deepStrictEqual(calls, ['followingOutgoingTurn']); }); - test('attaches GitHub references from outgoing messages', async () => { - const { service, gitStateService } = createGitHubReferencesContributions(disposables); - await service.outgoingTurn(outgoingTurn('github-references', 'Fix microsoft/vscode#42')); - - assert.deepStrictEqual(gitStateService.attachedGitHubReferences, [{ - session: 'agent-host-session://test', - text: 'Fix microsoft/vscode#42', - }]); - }); - test('propagates the terminal outcome reason', () => { const contributions = disposables.add(createContributions(disposables, ReasonContribution)); contributions.turnEnd(turnEnd('reason', { kind: 'cancelled' })); diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 0e9366352ed..23fb43da484 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -111,9 +111,9 @@ Sessions and chats expose provider-neutral file changes and changesets. Transpor Turn-level file changes route through `IChatResponseFileChangesService`. The editor workbench opens its standard multi-diff presentation; the Agents Window registers `SessionsChatResponseFileChangesService` to select its canonical Changes editor. Providers expose the data but do not choose the presentation. -### Artifacts and customizations +### Artifacts, references, and customizations -Sessions may expose artifacts recorded by the agent. These are session-scoped. Chats may expose the customizations used or read during their turns; these are chat-scoped. Providers that cannot determine either may omit the corresponding observable. +Sessions may expose the artifacts and references recorded by the agent. Both share one session-scoped observable and are told apart by `isArtifact`: an artifact is something the session produced that is not an ordinary workspace edit, while a reference is something it only points the user at. Consumers that surface one category must filter on that field rather than assuming the observable holds artifacts alone. Chats may expose the customizations used or read during their turns; these are chat-scoped. Providers that cannot determine either may omit the corresponding observable. ## Provider contract diff --git a/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts b/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts index 7f2d6d2729f..00c64234157 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts @@ -20,6 +20,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import type { IChatPillEntry, IChatPillSection } from '../../../../workbench/browser/chatPills.js'; +import { ChatPillSingleEntry, type IChatDropdownPillOptions } from '../../../../workbench/browser/chatDropdownPill.js'; import { openChatTurnFile, previewKind } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; import type { IImageCarouselCollection } from '../../../../workbench/contrib/imageCarousel/browser/imageCarouselTypes.js'; @@ -28,6 +29,27 @@ import type { IActiveSession } from '../../../services/sessions/common/sessionsM const OPEN_IMAGE_CAROUSEL_COMMAND_ID = 'workbench.action.chat.openImageInCarousel'; +/** Action id of the references pill. */ +export const SESSION_REFERENCES_PILL_ID = 'sessions.chatPills.references'; + +/** + * Presentation of the references pill. References are always summarized: the + * pill answers "what did this session point me at" with a count, rather than + * turning into whichever single reference happens to be recorded. + */ +export const sessionReferencesPillOptions: IChatDropdownPillOptions = { + widgetId: 'sessionReferences', + icon: Codicon.bookmark, + title: localize('sessionReferences.title', "References"), + summaryLabel: count => count === 1 + ? localize('sessionReferences.countSingle', "1 Reference") + : localize('sessionReferences.count', "{0} References", count), + summaryAriaLabel: count => count === 1 + ? localize('sessionReferences.showSingle', "Show 1 reference") + : localize('sessionReferences.show', "Show {0} references", count), + singleEntry: ChatPillSingleEntry.Summary, +}; + const artifactIcons: ReadonlyMap = new Map([ [SessionArtifactKind.PullRequest, Codicon.gitPullRequest], [SessionArtifactKind.Issue, Codicon.issues], @@ -151,9 +173,10 @@ function toEntry(artifact: ISessionArtifact, actions: ISessionArtifactActions): } /** - * Builds the artifact sections shown in the pill from the agent-set artifacts. - * Websites the browsers pill already lists are left out, so the same page is - * offered once across the two pills. + * Builds the sections shown in a pill from one group of agent-set entries — + * the artifacts pill and the references pill each build their own. Websites + * the browsers pill already lists are left out, so the same page is offered + * once across the pills. */ export function buildSessionArtifactSections(artifacts: readonly ISessionArtifact[], actions: ISessionArtifactActions, imageCarouselEnabled: boolean, browserUrls: ReadonlySet): readonly IChatPillSection[] { const entriesByKind = new Map(); @@ -219,14 +242,17 @@ export function buildSessionArtifactSections(artifacts: readonly ISessionArtifac return sections; } -/** Publishes a session's artifact sections for the chat input pill. */ +/** Publishes a session's artifact and reference sections for the chat input pills. */ export class SessionArtifacts extends Disposable { + /** Sections for the artifacts pill: what the session produced. */ readonly sections: IObservable; + /** Sections for the references pill: what the session points the user at. */ + readonly referenceSections: IObservable; constructor( session: IObservable, - /** The URLs the browsers pill lists; website artifacts for them are left out. */ + /** The URLs the browsers pill lists; website entries for them are left out. */ private readonly _browserUrls: IObservable>, @IClipboardService private readonly _clipboardService: IClipboardService, @ICommandService private readonly _commandService: ICommandService, @@ -237,18 +263,21 @@ export class SessionArtifacts extends Disposable { const imageCarouselEnabled = observableConfigValue(ChatConfiguration.ImageCarouselEnabled, true, this._configurationService); - this.sections = derived(this, reader => { + const sectionsFor = (isArtifact: boolean) => derived(this, reader => { const current = session.read(reader); if (!current) { return []; } return buildSessionArtifactSections( - current.artifacts?.read(reader) ?? [], + (current.artifacts?.read(reader) ?? []).filter(artifact => artifact.isArtifact === isArtifact), this._actions(), imageCarouselEnabled.read(reader), this._browserUrls.read(reader), ); }); + + this.sections = sectionsFor(true); + this.referenceSections = sectionsFor(false); } private _actions(): ISessionArtifactActions { diff --git a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts index a33a67386af..64d721bf057 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts @@ -18,7 +18,7 @@ import { IContextMenuService } from '../../../../platform/contextview/browser/co import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IChatResponseFileChangesService } from '../../../../workbench/contrib/chat/browser/chatResponseFileChangesService.js'; import { CHAT_TURN_ARTIFACT_PILL_ID, CHAT_TURN_CHANGES_PILL_ID, ChatTurnPillsProvider, diffStatsEqual, EMPTY_DIFF_STATS, IChatTurnPillsModel, IDiffStats, observeTurnStatusPillsEnabled } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; -import { SessionArtifacts, sessionArtifactLocation } from './sessionArtifacts.js'; +import { SessionArtifacts, sessionArtifactLocation, sessionReferencesPillOptions, SESSION_REFERENCES_PILL_ID } from './sessionArtifacts.js'; import { chatCustomizationPillOptions, SessionCustomizations, SESSION_CUSTOMIZATIONS_PILL_ID } from './sessionCustomizations.js'; import { localize } from '../../../../nls.js'; import { getChatPillEntries, ChatPillsWidget, IChatPill, IChatPillsModel, type IChatPillSection } from '../../../../workbench/browser/chatPills.js'; @@ -71,6 +71,8 @@ export function getSessionChatPillKindForAction(actionId: string): SessionChatPi return SessionChatPillKind.Changes; case CHAT_TURN_ARTIFACT_PILL_ID: return SessionChatPillKind.Artifacts; + case SESSION_REFERENCES_PILL_ID: + return SessionChatPillKind.References; case SESSION_CUSTOMIZATIONS_PILL_ID: return SessionChatPillKind.Customizations; case OPEN_PULL_REQUEST_ACTION_ID: @@ -128,6 +130,8 @@ export class SessionChatInputToolbar extends Disposable { private readonly _diffStats: IObservable; /** Artifact sections shown in the artifact pill. */ private readonly _artifactSections: IObservable; + /** Reference sections shown in the references pill. */ + private readonly _referenceSections: IObservable; /** Customization sections shown in the customizations pill. */ private readonly _customizationSections: IObservable; @@ -163,13 +167,14 @@ export class SessionChatInputToolbar extends Disposable { const visibility = this._register(instantiationService.createInstance(SessionChatPillVisibility)); this._browsers = this._register(instantiationService.createInstance(SessionBrowsersControl, this._session, this._chat, turnStatusPillsEnabled, derived(reader => visibility.isVisible(SessionChatPillKind.Browsers, reader)))); - // The browsers pill already offers the pages it lists, so the artifacts pill - // leaves those websites out. + // The browsers pill already offers the pages it lists, so the artifacts and + // references pills leave those websites out. const sessionArtifacts = this._register(instantiationService.createInstance(SessionArtifacts, this._session, this._browsers.urls)); this._artifactSections = derived(this, reader => { const debugData = this._debugData.read(reader); return debugData ? buildDebugArtifactSections(debugData) : sessionArtifacts.sections.read(reader); }); + this._referenceSections = sessionArtifacts.referenceSections; const sessionCustomizations = this._register(instantiationService.createInstance(SessionCustomizations, this._chat, this._session)); this._customizationSections = sessionCustomizations.sections; @@ -203,20 +208,28 @@ export class SessionChatInputToolbar extends Disposable { return createChatSectionPill(action, sections, options, resourceLabels, instantiationService); }; - // Customization sections are not gated at the source, so gate them here the - // way the two activity controls gate their own. Data presence follows the - // feature gate but not the user's visibility choice, otherwise hiding the - // pill would drop it from the menu that restores it. - const availableCustomizations = derived(reader => turnStatusPillsEnabled.read(reader) ? this._customizationSections.read(reader) : []); - const hasCustomizations = derived(reader => getChatPillEntries(availableCustomizations.read(reader)).length > 0); - const customizationSections = derived(reader => visibility.isVisible(SessionChatPillKind.Customizations, reader) - ? availableCustomizations.read(reader) - : []); + // Customization and reference sections are not gated at the source, so gate + // them here the way the two activity controls gate their own. Data presence + // follows the feature gate but not the user's visibility choice, otherwise + // hiding the pill would drop it from the menu that restores it. + const gated = (kind: SessionChatPillKind, source: IObservable) => { + const available = derived(reader => turnStatusPillsEnabled.read(reader) ? source.read(reader) : []); + return { + hasData: derived(reader => getChatPillEntries(available.read(reader)).length > 0), + sections: derived(reader => visibility.isVisible(kind, reader) ? available.read(reader) : []), + }; + }; + const customizations = gated(SessionChatPillKind.Customizations, this._customizationSections); + const references = gated(SessionChatPillKind.References, this._referenceSections); // Every section-backed pill lives in the same toolbar, so the whole row is // one tab stop with arrow-key navigation instead of one stop per pill. + // These follow the candidate pills, which is what puts References directly + // after the artifacts pill: the two read as a pair, what the session made + // and what it points at. const sectionPills: readonly { readonly pill: IObservable; readonly sections: IObservable }[] = [ - { pill: sectionPill(SESSION_CUSTOMIZATIONS_PILL_ID, localize('sessionChatPills.customizations', "Customizations"), customizationSections, chatCustomizationPillOptions), sections: customizationSections }, + { pill: sectionPill(SESSION_REFERENCES_PILL_ID, localize('sessionChatPills.references', "References"), references.sections, sessionReferencesPillOptions), sections: references.sections }, + { pill: sectionPill(SESSION_CUSTOMIZATIONS_PILL_ID, localize('sessionChatPills.customizations', "Customizations"), customizations.sections, chatCustomizationPillOptions), sections: customizations.sections }, { pill: sectionPill(SESSION_BROWSERS_PILL_ID, localize('sessionChatPills.browsers', "Browsers"), this._browsers.sections, sessionBrowsersPillOptions), sections: this._browsers.sections }, { pill: sectionPill(SESSION_SUBAGENTS_PILL_ID, localize('sessionChatPills.subagents', "Subagents"), this._backgroundActivities.sections, sessionSubagentsPillOptions), sections: this._backgroundActivities.sections }, ]; @@ -259,9 +272,12 @@ export class SessionChatInputToolbar extends Disposable { if (this._backgroundActivities.hasData.read(reader)) { kinds.add(SessionChatPillKind.Subagents); } - if (hasCustomizations.read(reader)) { + if (customizations.hasData.read(reader)) { kinds.add(SessionChatPillKind.Customizations); } + if (references.hasData.read(reader)) { + kinds.add(SessionChatPillKind.References); + } return kinds; }); this._register(addDisposableListener(this._content, EventType.CONTEXT_MENU, (e: MouseEvent) => { diff --git a/src/vs/sessions/contrib/chat/browser/sessionCustomizations.ts b/src/vs/sessions/contrib/chat/browser/sessionCustomizations.ts index fd3393c71b9..c14e893a4f7 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionCustomizations.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionCustomizations.ts @@ -13,7 +13,7 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import type { IChatDropdownPillOptions } from '../../../../workbench/browser/chatDropdownPill.js'; +import { ChatPillSingleEntry, type IChatDropdownPillOptions } from '../../../../workbench/browser/chatDropdownPill.js'; import { type IChatPillEntry, type IChatPillSection } from '../../../../workbench/browser/chatPills.js'; import { AICustomizationManagementCommands, AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { ISessionChatCustomization, ISessionFolder, SessionCustomizationKind, type IChat } from '../../../services/sessions/common/session.js'; @@ -33,7 +33,7 @@ export const chatCustomizationPillOptions: IChatDropdownPillOptions = { summaryAriaLabel: count => count === 1 ? localize('chatCustomizations.showSingle', "Show 1 customization") : localize('chatCustomizations.show', "Show {0} customizations", count), - alwaysSummarize: true, + singleEntry: ChatPillSingleEntry.Summary, }; const customizationIcons: ReadonlyMap = new Map([ diff --git a/src/vs/sessions/contrib/chat/common/sessionChatPills.ts b/src/vs/sessions/contrib/chat/common/sessionChatPills.ts index 188d8054a80..bc5c0c46e7f 100644 --- a/src/vs/sessions/contrib/chat/common/sessionChatPills.ts +++ b/src/vs/sessions/contrib/chat/common/sessionChatPills.ts @@ -13,6 +13,7 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo export const enum SessionChatPillKind { Changes = 'changes', Artifacts = 'artifacts', + References = 'references', Customizations = 'customizations', PullRequests = 'pullRequests', Issues = 'issues', @@ -24,6 +25,7 @@ export const enum SessionChatPillKind { export const SESSION_CHAT_PILL_KINDS: readonly SessionChatPillKind[] = [ SessionChatPillKind.Changes, SessionChatPillKind.Artifacts, + SessionChatPillKind.References, SessionChatPillKind.Customizations, SessionChatPillKind.PullRequests, SessionChatPillKind.Issues, @@ -35,6 +37,7 @@ export function getSessionChatPillLabel(kind: SessionChatPillKind): string { switch (kind) { case SessionChatPillKind.Changes: return localize('sessionChatPills.changes', "Changes"); case SessionChatPillKind.Artifacts: return localize('sessionChatPills.artifacts', "Artifacts"); + case SessionChatPillKind.References: return localize('sessionChatPills.references', "References"); case SessionChatPillKind.Customizations: return localize('sessionChatPills.customizations', "Customizations"); case SessionChatPillKind.PullRequests: return localize('sessionChatPills.pullRequests', "Pull Requests"); case SessionChatPillKind.Issues: return localize('sessionChatPills.issues', "Issues"); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.ts index a49ec329b66..baa3cb01e92 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.ts @@ -25,9 +25,9 @@ suite('Session Artifacts', () => { const resourceUri = URI.parse('vscode://sessions/resource'); const pullRequestLink = URI.parse('https://github.com/microsoft/vscode/pull/12'); const artifacts: readonly ISessionArtifact[] = [ - { id: 'pr', kind: SessionArtifactKind.PullRequest, label: 'PR #12', link: pullRequestLink }, - { id: 'file', kind: SessionArtifactKind.File, label: 'Report', uri: fileUri }, - { id: 'resource', kind: SessionArtifactKind.Resource, label: 'Resource', uri: resourceUri }, + { id: 'pr', kind: SessionArtifactKind.PullRequest, label: 'PR #12', isArtifact: true, link: pullRequestLink }, + { id: 'file', kind: SessionArtifactKind.File, label: 'Report', isArtifact: true, uri: fileUri }, + { id: 'resource', kind: SessionArtifactKind.Resource, label: 'Resource', isArtifact: true, uri: resourceUri }, ]; const entries = buildSessionArtifactSections(artifacts, actions, true, new Set()).flatMap(section => section.entries); @@ -50,11 +50,11 @@ suite('Session Artifacts', () => { test('leaves out websites the browsers pill already lists', () => { const pullRequestLink = URI.parse('https://github.com/microsoft/vscode/pull/12'); const artifacts: readonly ISessionArtifact[] = [ - { id: 'docs', kind: SessionArtifactKind.Website, label: 'Docs', link: URI.parse('https://example.com/docs') }, - { id: 'docs-slash', kind: SessionArtifactKind.Website, label: 'Docs Index', link: URI.parse('https://Example.com/docs/') }, - { id: 'deep', kind: SessionArtifactKind.Website, label: 'Deep Link', link: URI.parse('https://example.com/docs/api') }, - { id: 'blog', kind: SessionArtifactKind.Website, label: 'Blog', link: URI.parse('https://other.test/blog') }, - { id: 'pr', kind: SessionArtifactKind.PullRequest, label: 'PR #12', link: pullRequestLink }, + { id: 'docs', kind: SessionArtifactKind.Website, label: 'Docs', isArtifact: true, link: URI.parse('https://example.com/docs') }, + { id: 'docs-slash', kind: SessionArtifactKind.Website, label: 'Docs Index', isArtifact: true, link: URI.parse('https://Example.com/docs/') }, + { id: 'deep', kind: SessionArtifactKind.Website, label: 'Deep Link', isArtifact: true, link: URI.parse('https://example.com/docs/api') }, + { id: 'blog', kind: SessionArtifactKind.Website, label: 'Blog', isArtifact: true, link: URI.parse('https://other.test/blog') }, + { id: 'pr', kind: SessionArtifactKind.PullRequest, label: 'PR #12', isArtifact: true, link: pullRequestLink }, ]; const labels = (browserUrls: readonly string[]) => buildSessionArtifactSections(artifacts, actions, true, new Set(browserUrls)) .flatMap(section => section.entries) diff --git a/src/vs/sessions/contrib/chat/test/common/sessionChatPills.test.ts b/src/vs/sessions/contrib/chat/test/common/sessionChatPills.test.ts index a0e175a04e8..573248c7c5e 100644 --- a/src/vs/sessions/contrib/chat/test/common/sessionChatPills.test.ts +++ b/src/vs/sessions/contrib/chat/test/common/sessionChatPills.test.ts @@ -24,6 +24,7 @@ suite('SessionChatPills', () => { ], withoutData: [ { kind: SessionChatPillKind.Artifacts, label: 'Artifacts', checked: true }, + { kind: SessionChatPillKind.References, label: 'References', checked: true }, { kind: SessionChatPillKind.Customizations, label: 'Customizations', checked: true }, { kind: SessionChatPillKind.Issues, label: 'Issues', checked: true }, { kind: SessionChatPillKind.Browsers, label: 'Browsers', checked: true }, @@ -52,11 +53,13 @@ suite('SessionChatPills', () => { customizations: visibility.isVisible(SessionChatPillKind.Customizations, undefined), subagents: visibility.isVisible(SessionChatPillKind.Subagents, undefined), artifacts: visibility.isVisible(SessionChatPillKind.Artifacts, undefined), + references: visibility.isVisible(SessionChatPillKind.References, undefined), changes: visibility.isVisible(SessionChatPillKind.Changes, undefined), }, { customizations: false, subagents: false, artifacts: true, + references: true, changes: true, }); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index d3d26bf514c..483b0d80226 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -61,7 +61,7 @@ The provider cache owns adapter identity. Catalog notifications describe members Provider-specific metadata such as pull-request provenance, changesets, agent configuration, and external visibility is translated inside this provider. Shared Sessions code consumes only provider-neutral fields and capabilities. -Agent-recorded artifacts are persisted with the session and projected through `ISession.artifacts`. Pull request and issue artifacts that shared GitHub surfaces can represent are promoted into the existing GitHub metadata without duplicating them. Customizations used or read by the agent are derived per chat and projected through `IChat.customizations`. +Agent-recorded artifacts and references are persisted with the session and projected together through `ISession.artifacts`, where `isArtifact` distinguishes them. Only artifacts are promoted into the existing GitHub metadata, so a pull request or issue the session produced is polled and shown on the shared GitHub surfaces rather than duplicated; a reference keeps its link identity so anything those surfaces already show is offered exactly once. Customizations used or read by the agent are derived per chat and projected through `IChat.customizations`. ## Draft and send lifecycle diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionArtifacts.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionArtifacts.ts index f88329f88c5..80b1f4dbae4 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionArtifacts.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionArtifacts.ts @@ -47,6 +47,7 @@ function toSessionArtifact(artifact: IProtocolSessionArtifact): ISessionArtifact id: artifact.id, kind, label: artifact.label, + isArtifact: artifact.isArtifact, ...(link ? { link } : {}), ...(uri ? { uri } : {}), ...(artifact.commitHash ? { commitHash: artifact.commitHash } : {}), @@ -57,15 +58,15 @@ function toSessionArtifact(artifact: IProtocolSessionArtifact): ISessionArtifact /** * GitHub pull request and issue artifacts are promoted into the session's * GitHub links (polled and shown in their own pills) instead of the artifacts - * pill, so the two never show the same reference twice. + * pill, so the two never show the same link twice. References are never + * promoted: they belong to the references pill, which is where the user looks + * for what the session pointed at rather than produced. */ export interface ISessionArtifactPartition { - /** Every artifact in stream order, paired with the link it may be promoted by. */ + /** Every entry in stream order, paired with the GitHub link that identifies it. */ readonly entries: readonly ISessionArtifactEntry[]; - /** Pull requests this session created; eligible to become the main pull request. */ - readonly createdPullRequestUrls: readonly string[]; - /** Pull requests the session only referenced; listed and polled, never main. */ - readonly referencedPullRequestUrls: readonly string[]; + /** Pull requests this session produced; polled and shown in the pull request pill. */ + readonly pullRequestUrls: readonly string[]; /** * Titles the agent recorded for its pull request artifacts, keyed by * {@link linkKey}. Pull requests discovered from git state have no entry. @@ -74,10 +75,15 @@ export interface ISessionArtifactPartition { readonly issueUrls: readonly string[]; } -/** An artifact, and the GitHub link it is promoted by when it has one. */ +/** An entry, and the GitHub link that identifies it when it has one. */ export interface ISessionArtifactEntry { readonly artifact: ISessionArtifact; - readonly promotedLink?: string; + /** + * The GitHub pull request or issue link this entry stands for. Set for + * references too, so an entry can be recognized as one the GitHub pills + * already surface even though only artifacts are promoted into them. + */ + readonly gitHubLink?: string; } /** Normalized key for comparing links irrespective of case and trailing slash. */ @@ -86,23 +92,23 @@ export function linkKey(link: string): string { } /** - * The artifacts the pill shows: everything except the promoted references that - * the GitHub pills actually surfaced. A promotion the session cannot surface — - * no repository, or a reference belonging to another repository — stays an - * artifact rather than disappearing from both places. + * The artifacts and references the pills show: everything except the entries + * the GitHub pills actually surfaced. A link the session cannot surface — no + * repository, or one belonging to another repository — stays here rather than + * disappearing from both places. */ export function getPresentedArtifacts(partition: ISessionArtifactPartition, surfacedLinks: ReadonlySet): readonly ISessionArtifact[] { return partition.entries - .filter(entry => !entry.promotedLink || !surfacedLinks.has(linkKey(entry.promotedLink))) + .filter(entry => !entry.gitHubLink || !surfacedLinks.has(linkKey(entry.gitHubLink))) .map(entry => entry.artifact); } /** - * Only links the pull request and issue pills can actually render are promoted; - * anything else (an enterprise host, a malformed link) stays an artifact so it - * never disappears from both places. + * The GitHub link an entry stands for, when the pull request and issue pills + * could actually render it. Anything else (an enterprise host, a malformed + * link) has no link identity and simply stays in its pill. */ -function promotedLink(artifact: IProtocolSessionArtifact): string | undefined { +function gitHubLink(artifact: IProtocolSessionArtifact): string | undefined { if (artifact.isGitHub !== true || !artifact.link) { return undefined; } @@ -117,8 +123,7 @@ function promotedLink(artifact: IProtocolSessionArtifact): string | undefined { export function partitionSessionArtifacts(meta: SessionMeta | undefined): ISessionArtifactPartition { const entries: ISessionArtifactEntry[] = []; - const createdPullRequestUrls: string[] = []; - const referencedPullRequestUrls: string[] = []; + const pullRequestUrls: string[] = []; const pullRequestTitles = new Map(); const issueUrls: string[] = []; @@ -127,9 +132,11 @@ export function partitionSessionArtifacts(meta: SessionMeta | undefined): ISessi if (!mapped) { continue; } - const link = promotedLink(artifact); - entries.push(link ? { artifact: mapped, promotedLink: link } : { artifact: mapped }); - if (!link) { + const link = gitHubLink(artifact); + entries.push(link ? { artifact: mapped, gitHubLink: link } : { artifact: mapped }); + // Only what the session produced is promoted into the GitHub pills; a + // reference keeps its link identity but is never polled. + if (!link || !artifact.isArtifact) { continue; } @@ -144,14 +151,10 @@ export function partitionSessionArtifacts(meta: SessionMeta | undefined): ISessi if (mapped.label && !pullRequestTitles.has(key)) { pullRequestTitles.set(key, mapped.label); } - if (artifact.createdByThisSession) { - createdPullRequestUrls.push(link); - } else { - referencedPullRequestUrls.push(link); - } + pullRequestUrls.push(link); } - return { entries, createdPullRequestUrls, referencedPullRequestUrls, pullRequestTitles, issueUrls }; + return { entries, pullRequestUrls, pullRequestTitles, issueUrls }; } /** Case-insensitive de-duplication that keeps the first occurrence's casing. */ diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 0d965bc799f..a7600c2891e 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -339,21 +339,21 @@ function toGitHubIssueRefs(issueUrls: readonly string[] | undefined): readonly I /** * Maps session pull request URLs to references, preserving recency order. * - * `titles` and `createdLinks` are keyed by {@link linkKey}; a URL missing from - * either simply carries no title / is not marked as created by the session. + * `titles` is keyed by {@link linkKey}; a URL missing from it simply carries no + * title. Every pull request published here belongs to the session — it either + * produced it or its branch relates to it — so all are marked as such. */ -function toGitHubPullRequestRefs(pullRequestUrls: readonly string[] | undefined, titles: ReadonlyMap, createdLinks: ReadonlySet): readonly IGitHubPullRequestRef[] | undefined { +function toGitHubPullRequestRefs(pullRequestUrls: readonly string[] | undefined, titles: ReadonlyMap): readonly IGitHubPullRequestRef[] | undefined { const refs: IGitHubPullRequestRef[] = []; for (const url of pullRequestUrls ?? []) { const reference = parseGitHubPullRequestUrl(url); if (reference) { - const key = linkKey(url); - const title = titles.get(key); + const title = titles.get(linkKey(url)); refs.push({ ...reference, uri: URI.parse(url), ...(title ? { title } : {}), - ...(createdLinks.has(key) ? { createdByThisSession: true } : {}), + createdByThisSession: true, }); } } @@ -361,8 +361,8 @@ function toGitHubPullRequestRefs(pullRequestUrls: readonly string[] | undefined, } /** - * The GitHub info for a session, plus the promoted artifact links it surfaced. - * Anything it could not surface stays in the artifacts pill. + * The GitHub info for a session, plus the links its pills actually surfaced. + * Anything they could not surface stays in the artifacts or references pill. */ interface IGitHubPromotion { readonly info: IGitHubInfo | undefined; @@ -372,13 +372,11 @@ interface IGitHubPromotion { function toGitHubPromotion(meta: SessionMeta | undefined): IGitHubPromotion { const state = readSessionGitHubState(meta); const gitState = readSessionGitState(meta); - const { createdPullRequestUrls, referencedPullRequestUrls, pullRequestTitles, issueUrls } = partitionSessionArtifacts(meta); + const { pullRequestUrls, pullRequestTitles, issueUrls } = partitionSessionArtifacts(meta); - // Pull requests this session created outrank discovered ones for the main - // slot; referenced ones are listed and polled but never become main. - const mainEligibleUrls = dedupeLinks(createdPullRequestUrls, getSessionRelatedPullRequestUrls(state)); - const mainEligible = new Set(mainEligibleUrls.map(linkKey)); - const allPullRequests = toGitHubPullRequestRefs(dedupeLinks(mainEligibleUrls, referencedPullRequestUrls), pullRequestTitles, mainEligible); + // Only pull requests the session produced are promoted, so the ones it + // recorded lead the discovered ones and the first is the main pull request. + const allPullRequests = toGitHubPullRequestRefs(dedupeLinks(pullRequestUrls, getSessionRelatedPullRequestUrls(state)), pullRequestTitles); const repository = state?.owner && state.repo ? { owner: state.owner, repo: state.repo } : gitState?.githubOwner && gitState.githubRepo @@ -389,20 +387,22 @@ function toGitHubPromotion(meta: SessionMeta | undefined): IGitHubPromotion { return { info: undefined, surfacedLinks: new Set() }; } - // A session carries one repository, so a reference from another repository - // would be polled against the wrong coordinates. Leave those as artifacts. + // A session carries one repository, so a link from another repository would + // be polled against the wrong coordinates. Leave those in their own pill. const belongsToRepository = (ref: { readonly owner: string; readonly repo: string }) => ref.owner.toLowerCase() === repository.owner.toLowerCase() && ref.repo.toLowerCase() === repository.repo.toLowerCase(); const pullRequests = allPullRequests?.filter(belongsToRepository); - const pullRequest = pullRequests?.find(ref => mainEligible.has(linkKey(ref.uri.toString()))); - const issues = toGitHubIssueRefs(dedupeLinks(state?.issueUrls, issueUrls))?.filter(belongsToRepository); + const pullRequest = pullRequests?.at(0); + const issues = toGitHubIssueRefs(dedupeLinks(issueUrls))?.filter(belongsToRepository); - const promotedLinks = new Set([...createdPullRequestUrls, ...referencedPullRequestUrls, ...issueUrls].map(linkKey)); + // Everything the GitHub pills actually render, whichever source produced it. + // An entry standing for one of these links is left out of the artifacts and + // references pills, so the user is offered it exactly once. const surfacedLinks = new Set([ ...(pullRequests ?? []).map(ref => linkKey(ref.uri.toString())), ...(issues ?? []).map(ref => linkKey(ref.uri.toString())), - ].filter(link => promotedLinks.has(link))); + ]); return { info: { diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 0faaa861833..684b0db51e7 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -6228,14 +6228,17 @@ suite('LocalAgentHostSessionsProvider', () => { owner: 'owner', repo: 'repo', pullRequestUrls: ['https://github.com/owner/repo/pull/41'], - issueUrls: ['https://github.com/owner/repo/issues/1'], }), [ - { id: 'a1', type: SessionArtifactType.PullRequest, label: 'Created', link: 'https://github.com/owner/repo/pull/50', isGitHub: true, createdByThisSession: true }, - { id: 'a2', type: SessionArtifactType.PullRequest, label: 'Referenced', link: 'https://github.com/owner/repo/pull/60', isGitHub: true, createdByThisSession: false }, - { id: 'a3', type: SessionArtifactType.PullRequest, label: 'Duplicate', link: 'https://github.com/owner/repo/pull/41/', isGitHub: true, createdByThisSession: false }, - { id: 'a4', type: SessionArtifactType.Issue, label: 'Issue', link: 'https://github.com/owner/repo/issues/7', isGitHub: true }, - { id: 'a5', type: SessionArtifactType.PullRequest, label: 'Elsewhere', link: 'https://gitlab.com/owner/repo/-/merge_requests/3', isGitHub: false, createdByThisSession: false }, - { id: 'a6', type: SessionArtifactType.File, label: 'Plan', uri: 'file:///repo/plan.md' }, + { id: 'a1', type: SessionArtifactType.PullRequest, label: 'Created', isArtifact: true, link: 'https://github.com/owner/repo/pull/50', isGitHub: true }, + { id: 'a2', type: SessionArtifactType.PullRequest, label: 'Referenced', isArtifact: false, link: 'https://github.com/owner/repo/pull/60', isGitHub: true }, + { id: 'a3', type: SessionArtifactType.PullRequest, label: 'Duplicate', isArtifact: true, link: 'https://github.com/owner/repo/pull/41/', isGitHub: true }, + { id: 'a4', type: SessionArtifactType.Issue, label: 'Issue', isArtifact: true, link: 'https://github.com/owner/repo/issues/7', isGitHub: true }, + { id: 'a5', type: SessionArtifactType.PullRequest, label: 'Elsewhere', isArtifact: true, link: 'https://gitlab.com/owner/repo/-/merge_requests/3', isGitHub: false }, + { id: 'a6', type: SessionArtifactType.File, label: 'Plan', isArtifact: true, uri: 'file:///repo/plan.md' }, + { id: 'a7', type: SessionArtifactType.Issue, label: 'Referenced issue', isArtifact: false, link: 'https://github.com/owner/repo/issues/8', isGitHub: true }, + // The pull request discovered from git state, also recorded as a + // reference: the pull request pill already shows it, so it is dropped here. + { id: 'a8', type: SessionArtifactType.PullRequest, label: 'Discovered', isArtifact: false, link: 'https://github.com/owner/repo/pull/41', isGitHub: true }, ]); agentHost.setSessionState('pr-artifacts', 'copilotcli', { provider: 'copilotcli', title: 'Artifact Session', status: ProtocolSessionStatus.Idle, @@ -6253,9 +6256,10 @@ suite('LocalAgentHostSessionsProvider', () => { artifacts: session.artifacts?.get().map(artifact => artifact.id), }, { activePullRequest: 50, - pullRequests: [50, 41, 60], - issues: [1, 7], - artifacts: ['a5', 'a6'], + pullRequests: [50, 41], + // Only issues the session produced are polled; a referenced one stays a reference. + issues: [7], + artifacts: ['a2', 'a5', 'a6', 'a7'], }); })); @@ -6281,7 +6285,7 @@ suite('LocalAgentHostSessionsProvider', () => { activeClients: [], chats: [], _meta: withSessionArtifacts(undefined, [ - { id: 'a1', type: SessionArtifactType.Issue, label: 'Orphan issue', link: 'https://github.com/owner/repo/issues/7', isGitHub: true }, + { id: 'a1', type: SessionArtifactType.Issue, label: 'Orphan issue', isArtifact: true, link: 'https://github.com/owner/repo/issues/7', isGitHub: true }, ]), }); const withoutRepository = session.artifacts?.get().map(artifact => artifact.id); @@ -6292,8 +6296,8 @@ suite('LocalAgentHostSessionsProvider', () => { activeClients: [], chats: [], _meta: withSessionArtifacts(withSessionGitHubState(undefined, { owner: 'owner', repo: 'repo' }), [ - { id: 'a1', type: SessionArtifactType.Issue, label: 'Same repo', link: 'https://github.com/owner/repo/issues/7', isGitHub: true }, - { id: 'a2', type: SessionArtifactType.PullRequest, label: 'Other repo', link: 'https://github.com/other/project/pull/9', isGitHub: true, createdByThisSession: true }, + { id: 'a1', type: SessionArtifactType.Issue, label: 'Same repo', isArtifact: true, link: 'https://github.com/owner/repo/issues/7', isGitHub: true }, + { id: 'a2', type: SessionArtifactType.PullRequest, label: 'Other repo', isArtifact: true, link: 'https://github.com/other/project/pull/9', isGitHub: true }, ]), }); const gitHubInfo = session.workspace.get()!.folders[0]!.gitRepository!.gitHubInfo.get(); diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 45f5ac898f4..18d46ff3142 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -234,7 +234,7 @@ export function getSessionWorkspaceKind(workspace: ISessionWorkspace | undefined } /** - * The kinds of artifact an agent can record on a session. + * The kinds of artifact or reference an agent can record on a session. */ export const enum SessionArtifactKind { PullRequest = 'pullRequest', @@ -250,6 +250,11 @@ export interface ISessionArtifact { readonly id: string; readonly kind: SessionArtifactKind; readonly label: string; + /** + * `true` for an artifact — something the session produced — and `false` for + * a reference, something it only points the user at. + */ + readonly isArtifact: boolean; /** Link opened when activating a pull request, issue, commit or website. */ readonly link?: URI; /** Resource opened when activating a file or resource artifact. */ @@ -701,7 +706,12 @@ export interface ISession { readonly changes: IObservable; /** Changesets produced by the session. */ readonly changesets: IObservable; - /** Artifacts the agent recorded for this session (pull requests, issues, files, …). */ + /** + * The artifacts and references the agent recorded for this session (pull + * requests, issues, files, …). Both categories share this observable and are + * told apart by {@link ISessionArtifact.isArtifact}, so a consumer that + * surfaces only one of them must filter on that field. + */ readonly artifacts?: IObservable; /** Currently selected model identifier. */ readonly modelId: IObservable; diff --git a/src/vs/workbench/browser/chatDropdownPill.ts b/src/vs/workbench/browser/chatDropdownPill.ts index 99ede54599e..8414c0b0bad 100644 --- a/src/vs/workbench/browser/chatDropdownPill.ts +++ b/src/vs/workbench/browser/chatDropdownPill.ts @@ -22,6 +22,22 @@ import { ChatResourcePillActionViewItem } from './chatResourcePill.js'; import type { ResourceLabels } from './labels.js'; import type { IInstantiationService } from '../../platform/instantiation/common/instantiation.js'; +/** + * How a pill holding exactly one entry renders. Several entries always collapse + * into the summary and its dropdown. + */ +export const enum ChatPillSingleEntry { + /** The entry itself — its own icon and label — activated directly. */ + Inline = 'inline', + /** + * The entry itself only when it is a resource, so a file keeps its name and + * themed icon; anything else summarizes. + */ + InlineResource = 'inlineResource', + /** The summary and its dropdown, as for several entries. */ + Summary = 'summary', +} + /** Presentation of a {@link ChatDropdownPillActionViewItem}. */ export interface IChatDropdownPillOptions { /** Identifies the pill's dropdown to the action widget service. */ @@ -34,11 +50,26 @@ export interface IChatDropdownPillOptions { readonly summaryLabel: (count: number) => string; /** Accessible summary label, e.g. `Show 3 artifacts`. */ readonly summaryAriaLabel: (count: number) => string; - /** - * Keeps the summary and dropdown even for a single entry, instead of - * collapsing to that entry's own icon and label. - */ - readonly alwaysSummarize?: boolean; + /** How a lone entry renders. Defaults to {@link ChatPillSingleEntry.Inline}. */ + readonly singleEntry?: ChatPillSingleEntry; +} + +/** + * The entry a pill shows in place of its summary, or `undefined` when it + * summarizes. The single place {@link IChatDropdownPillOptions.singleEntry} is + * interpreted, shared by the factory that picks the rendering and the view item + * that picks the label. + */ +function getInlineEntry(entries: readonly IChatPillEntry[], options: IChatDropdownPillOptions): IChatPillEntry | undefined { + if (entries.length !== 1) { + return undefined; + } + const entry = entries[0]; + switch (options.singleEntry ?? ChatPillSingleEntry.Inline) { + case ChatPillSingleEntry.Inline: return entry; + case ChatPillSingleEntry.InlineResource: return entry.resource ? entry : undefined; + case ChatPillSingleEntry.Summary: return undefined; + } } /** @@ -95,8 +126,8 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { /** Whether the pill stands for its entries rather than showing a single one. */ protected get isSummarized(): boolean { - const count = this.entries.length; - return count > 1 || (count > 0 && !!this._pillOptions.alwaysSummarize); + const entries = this.entries; + return entries.length > 0 && !getInlineEntry(entries, this._pillOptions); } protected get entries(): readonly IChatPillEntry[] { @@ -226,7 +257,8 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { /** * Builds the pill for a set of sections, choosing the rendering that fits the * data: a lone resource entry renders as a resource pill, everything else as - * the dropdown pill (which itself collapses to `icon + label` for one entry). + * the dropdown pill (which itself collapses to `icon + label` for one entry, + * unless its {@link IChatDropdownPillOptions.singleEntry} policy says otherwise). * * The descriptor identity only changes when the rendering has to change, so the * toolbar rebuilds the view item on a shape flip and updates in place otherwise. @@ -239,11 +271,8 @@ export function createChatSectionPill( instantiationService: IInstantiationService, ): IObservable { const singleResourceEntry = derived(reader => { - if (options.alwaysSummarize) { - return undefined; - } - const entries = getChatPillEntries(sections.read(reader)); - return entries.length === 1 && entries[0].resource ? entries[0] : undefined; + const entry = getInlineEntry(getChatPillEntries(sections.read(reader)), options); + return entry?.resource ? entry : undefined; }); const isResource = derived(reader => !!singleResourceEntry.read(reader)); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatTurnPills.ts b/src/vs/workbench/contrib/chat/browser/widget/chatTurnPills.ts index 9cbecae5eef..264f324f133 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatTurnPills.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatTurnPills.ts @@ -22,15 +22,25 @@ import { ChatConfiguration } from '../../common/constants.js'; import { getEditorOverrideForChatResource } from './chatEditorAssociations.js'; import { ChatPillsWidget, getChatPillEntries, IChatPill, type IChatPillSection } from '../../../../browser/chatPills.js'; import { ChatChangesPillActionViewItem } from '../../../../browser/chatChangesPill.js'; -import { createChatSectionPill, type IChatDropdownPillOptions } from '../../../../browser/chatDropdownPill.js'; +import { ChatPillSingleEntry, createChatSectionPill, type IChatDropdownPillOptions } from '../../../../browser/chatDropdownPill.js'; -/** Presentation of the artifacts pill. */ +/** + * Presentation of the artifacts pill. Only a file artifact is worth showing in + * place of the summary — its name and themed icon say what it is — while any + * other lone artifact stays behind the count, so the row keeps a stable shape + * instead of turning into whichever artifact happens to be recorded first. + */ export const chatArtifactPillOptions: IChatDropdownPillOptions = { widgetId: 'chatArtifacts', icon: Codicon.package, title: localize('chatArtifacts.title', "Artifacts"), - summaryLabel: count => localize('chatArtifacts.count', "{0} Artifacts", count), - summaryAriaLabel: count => localize('chatArtifacts.show', "Show {0} artifacts", count), + summaryLabel: count => count === 1 + ? localize('chatArtifacts.countSingle', "1 Artifact") + : localize('chatArtifacts.count', "{0} Artifacts", count), + summaryAriaLabel: count => count === 1 + ? localize('chatArtifacts.showSingle', "Show 1 artifact") + : localize('chatArtifacts.show', "Show {0} artifacts", count), + singleEntry: ChatPillSingleEntry.InlineResource, }; export const CHAT_TURN_CHANGES_PILL_ID = 'chat.turnPills.changes'; diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatTurnPills.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatTurnPills.test.ts index 229fbf43ef0..f878922c52e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatTurnPills.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatTurnPills.test.ts @@ -256,6 +256,36 @@ suite('ChatTurnPills', () => { }); }); + test('summarizes a lone artifact, keeping only a file artifact inline', () => { + const renderArtifact = (section: IChatPillSection) => { + const instantiationService = workbenchInstantiationService(undefined, disposables); + const widget = disposables.add(instantiationService.createInstance(ChatTurnPillsWidget, { + stats: constObservable(EMPTY_DIFF_STATS), + artifacts: constObservable([section]), + changesEnabled: constObservable(false), + artifactsEnabled: constObservable(true), + openChanges() { }, + })); + mainWindow.document.body.appendChild(widget.element); + disposables.add(toDisposable(() => widget.element.remove())); + + const button = widget.element.querySelector('.chat-pill-button'); + return { + rendering: button?.classList.contains('chat-resource-pill-button') ? 'resource' : 'dropdown', + label: button?.querySelector('.chat-pill-label')?.textContent, + ariaLabel: button?.getAttribute('aria-label'), + }; + }; + + assert.deepStrictEqual({ + pullRequest: renderArtifact({ title: 'Pull Requests', entries: [{ id: 'pr', label: '#12', icon: Codicon.gitPullRequest, ariaLabel: 'Open #12', open: () => { } }] }), + file: renderArtifact({ title: 'Files', entries: [{ id: 'file', label: 'plan.md', resource: URI.file('/artifacts/plan.md'), ariaLabel: 'Open plan.md', open: () => { } }] }), + }, { + pullRequest: { rendering: 'dropdown', label: '1 Artifact', ariaLabel: 'Show 1 artifact' }, + file: { rendering: 'resource', label: undefined, ariaLabel: 'Open plan.md' }, + }); + }); + test('opens a markdown resource with its configured chat editor association', async () => { const resource = URI.file('/workspace/README.md'); let opened: { resource: string; options: OpenInternalOptions | OpenExternalOptions | undefined } | undefined; diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts index e173f812297..0ab99a9e91c 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts @@ -55,7 +55,7 @@ interface ISessionSpec { readonly turnChanges?: readonly ISessionTurnFileChange[]; readonly browsers?: readonly { readonly title?: string; readonly ownerSubagent?: number }[]; readonly subagents?: readonly string[]; - /** Artifacts the agent recorded on the session. */ + /** Artifacts and references the agent recorded on the session. */ readonly artifacts?: readonly ISessionArtifact[]; /** Customizations the chat used or read. */ readonly customizations?: readonly ISessionChatCustomization[]; @@ -272,29 +272,48 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { })), }), - // --- Agent-set artifacts ------------------------------------------------- + // --- Agent-set artifacts and references ---------------------------------- SessionChatPills_ArtifactSingleFile: defineComponentFixture({ render: (ctx) => renderPills(ctx, createMockSession({ - artifacts: [{ id: 'a1', kind: SessionArtifactKind.File, label: 'Implementation plan', uri: URI.file('/repo/docs/plan.md') }], + artifacts: [{ id: 'a1', kind: SessionArtifactKind.File, label: 'Implementation plan', isArtifact: true, uri: URI.file('/repo/docs/plan.md') }], })), }), SessionChatPills_ArtifactSinglePullRequest: defineComponentFixture({ render: (ctx) => renderPills(ctx, createMockSession({ - artifacts: [{ id: 'a1', kind: SessionArtifactKind.PullRequest, label: 'Fix login redirect', link: URI.parse('https://github.com/microsoft/vscode/pull/1234'), isGitHub: true }], + artifacts: [{ id: 'a1', kind: SessionArtifactKind.PullRequest, label: 'Fix login redirect', isArtifact: true, link: URI.parse('https://github.com/microsoft/vscode/pull/1234'), isGitHub: true }], })), }), SessionChatPills_ArtifactsEveryType: defineComponentFixture({ render: (ctx) => renderPills(ctx, createMockSession({ artifacts: [ - { id: 'a1', kind: SessionArtifactKind.PullRequest, label: 'Fix login redirect', link: URI.parse('https://github.com/microsoft/vscode/pull/1234'), isGitHub: true }, - { id: 'a2', kind: SessionArtifactKind.Issue, label: 'Crash on startup', link: URI.parse('https://github.com/microsoft/vscode/issues/99'), isGitHub: true }, - { id: 'a3', kind: SessionArtifactKind.Commit, label: 'Extract auth helper', link: URI.parse('https://github.com/microsoft/vscode/commit/abc1234'), commitHash: 'abc1234' }, - { id: 'a4', kind: SessionArtifactKind.Website, label: 'Design doc', link: URI.parse('https://example.com/design') }, - { id: 'a5', kind: SessionArtifactKind.File, label: 'Implementation plan', uri: URI.file('/repo/docs/plan.md') }, - { id: 'a6', kind: SessionArtifactKind.Resource, label: 'Dashboard', uri: URI.parse('https://example.com/dashboard') }, + { id: 'a1', kind: SessionArtifactKind.PullRequest, label: 'Fix login redirect', isArtifact: true, link: URI.parse('https://github.com/microsoft/vscode/pull/1234'), isGitHub: true }, + { id: 'a2', kind: SessionArtifactKind.Issue, label: 'Crash on startup', isArtifact: true, link: URI.parse('https://github.com/microsoft/vscode/issues/99'), isGitHub: true }, + { id: 'a3', kind: SessionArtifactKind.Commit, label: 'Extract auth helper', isArtifact: true, link: URI.parse('https://github.com/microsoft/vscode/commit/abc1234'), commitHash: 'abc1234' }, + { id: 'a4', kind: SessionArtifactKind.Website, label: 'Design doc', isArtifact: true, link: URI.parse('https://example.com/design') }, + { id: 'a5', kind: SessionArtifactKind.File, label: 'Implementation plan', isArtifact: true, uri: URI.file('/repo/docs/plan.md') }, + { id: 'a6', kind: SessionArtifactKind.Resource, label: 'Dashboard', isArtifact: true, uri: URI.parse('https://example.com/dashboard') }, + ], + })), + }), + + // A single reference still summarizes as a count, unlike a single artifact. + SessionChatPills_ReferenceSingle: defineComponentFixture({ + render: (ctx) => renderPills(ctx, createMockSession({ + artifacts: [{ id: 'r1', kind: SessionArtifactKind.Commit, label: 'Commit that broke login', isArtifact: false, link: URI.parse('https://github.com/microsoft/vscode/commit/def5678'), commitHash: 'def5678' }], + })), + }), + + SessionChatPills_ArtifactsAndReferences: defineComponentFixture({ + render: (ctx) => renderPills(ctx, createMockSession({ + artifacts: [ + { id: 'a1', kind: SessionArtifactKind.PullRequest, label: 'Fix login redirect', isArtifact: true, link: URI.parse('https://github.com/microsoft/vscode/pull/1234'), isGitHub: true }, + { id: 'a2', kind: SessionArtifactKind.File, label: 'Implementation plan', isArtifact: true, uri: URI.file('/repo/docs/plan.md') }, + { id: 'r1', kind: SessionArtifactKind.Issue, label: 'Crash on startup', isArtifact: false, link: URI.parse('https://github.com/microsoft/vscode/issues/99'), isGitHub: true }, + { id: 'r2', kind: SessionArtifactKind.Commit, label: 'Commit that broke login', isArtifact: false, link: URI.parse('https://github.com/microsoft/vscode/commit/def5678'), commitHash: 'def5678' }, + { id: 'r3', kind: SessionArtifactKind.Website, label: 'OAuth redirect spec', isArtifact: false, link: URI.parse('https://example.com/spec') }, ], })), }), From 998a27930442ebdbcca033ab6141c8f2119a3504 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:40:13 +0000 Subject: [PATCH 044/116] Open Chat Customizations from Configure Custom Agents (#332334) * Initial plan * Route custom agent configuration to customizations Co-authored-by: aeschli <6461412+aeschli@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: aeschli <6461412+aeschli@users.noreply.github.com> Co-authored-by: Martin Aeschlimann --- .../chat/browser/promptSyntax/chatModeActions.ts | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/chatModeActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/chatModeActions.ts index fa70cb159fd..b20fd41934a 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/chatModeActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/chatModeActions.ts @@ -7,25 +7,15 @@ import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { localize, localize2 } from '../../../../../nls.js'; -import { PromptFilePickers } from './pickers/promptFilePickers.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; -import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; -import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { AICustomizationManagementCommands, AICustomizationManagementSection } from '../aiCustomization/aiCustomizationManagement.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; abstract class ConfigAgentActionImpl extends Action2 { public override async run(accessor: ServicesAccessor): Promise { - const instaService = accessor.get(IInstantiationService); - const openerService = accessor.get(IOpenerService); - const pickers = instaService.createInstance(PromptFilePickers); - const placeholder = localize('configure.agent.prompts.placeholder', "Select the custom agents to open and configure visibility in the agent picker"); - - const result = await pickers.selectPromptFile({ placeholder, type: PromptsType.agent, optionEdit: false, optionVisibility: true }); - if (result !== undefined) { - await openerService.open(result.promptFile); - } + await accessor.get(ICommandService).executeCommand(AICustomizationManagementCommands.OpenEditor, AICustomizationManagementSection.Agents); } } From 8df2bf6514fc8efd99f3d605d0ec9f7a725d0f75 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Wed, 26 Aug 2026 02:40:54 -0700 Subject: [PATCH 045/116] Route terminal output sources through active parts (#332472) * Route terminal output sources through active parts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove stale output source event override Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: dmitrivMS <9581278+dmitrivMS@users.noreply.github.com> Co-authored-by: Anthony Kim <62267334+anthonykim1@users.noreply.github.com> --- .../chatTerminalToolProgressPart.ts | 11 +++--- .../chatTerminalToolProgressPart.test.ts | 1 - .../contrib/terminal/browser/terminal.ts | 2 +- .../chat/browser/terminalChatService.ts | 6 +-- .../test/browser/terminalChatService.test.ts | 37 ++++++++++++++++++- 5 files changed, 46 insertions(+), 11 deletions(-) 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 25310ee3d4c..c997013506d 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 @@ -446,11 +446,6 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart if (terminalToolSessionId) { if (this._terminalData.isPty === false) { this._attachOutputSource(); - this._register(this._terminalChatService.onDidRegisterOutputSource(sessionId => { - if (sessionId === terminalToolSessionId) { - this._attachOutputSource(); - } - })); } } let pastTenseMessage: string | undefined; @@ -1083,6 +1078,12 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart return this._terminalInstance; } + public didRegisterOutputSource(terminalToolSessionId: string): void { + if (this._terminalData.isPty === false && this._terminalData.terminalToolSessionId === terminalToolSessionId) { + this._attachOutputSource(); + } + } + private _attachOutputSource(): void { const source = this._terminalChatService.getOutputSource(this._terminalData.terminalToolSessionId); if (!source || source === this._outputSource) { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTerminalToolProgressPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTerminalToolProgressPart.test.ts index bee11808a7e..7b3ec29758a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTerminalToolProgressPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTerminalToolProgressPart.test.ts @@ -41,7 +41,6 @@ function listenerCount(emitter: Emitter): number { class TestTerminalChatService extends mock() { override readonly onDidRegisterTerminalInstanceWithToolSession = Event.None; - override readonly onDidRegisterOutputSource = Event.None; override readonly onDidContinueInBackground: Event; private readonly progressParts = new Set(); diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index 0dcfa551ee8..5ecd0ab420b 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -128,6 +128,7 @@ export interface IChatTerminalToolProgressPart { toggleOutputFromKeyboard(): Promise; toggleOutputFromAction(): Promise; continueInBackground(): void; + didRegisterOutputSource(terminalToolSessionId: string): void; markContinuedInBackground(): void; focusOutput(): void; getCommandAndOutputAsText(): string | undefined; @@ -154,7 +155,6 @@ export interface ITerminalChatService { * the chat UI first renders, enabling late binding of the focus action. */ readonly onDidRegisterTerminalInstanceWithToolSession: Event; - readonly onDidRegisterOutputSource: Event; /** * Associate a tool session id with a terminal instance. The association is automatically diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts index 33c5d43295d..eace86755e7 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts @@ -48,8 +48,6 @@ export class TerminalChatService extends Disposable implements ITerminalChatServ readonly onDidContinueInBackground: Event = this._onDidContinueInBackground.event; private readonly _onDidRegisterTerminalInstanceForToolSession = this._register(new Emitter()); readonly onDidRegisterTerminalInstanceWithToolSession: Event = this._onDidRegisterTerminalInstanceForToolSession.event; - private readonly _onDidRegisterOutputSource = this._register(new Emitter()); - readonly onDidRegisterOutputSource: Event = this._onDidRegisterOutputSource.event; private readonly _activeProgressParts = new Set(); private _focusedProgressPart: IChatTerminalToolProgressPart | undefined; @@ -254,7 +252,9 @@ export class TerminalChatService extends Disposable implements ITerminalChatServ registerOutputSource(terminalToolSessionId: string, source: IChatTerminalOutputSource): IDisposable { this._outputSources.set(terminalToolSessionId, source); - this._onDidRegisterOutputSource.fire(terminalToolSessionId); + for (const part of this._activeProgressParts) { + part.didRegisterOutputSource(terminalToolSessionId); + } return toDisposable(() => { if (this._outputSources.get(terminalToolSessionId) === source) { this._outputSources.delete(terminalToolSessionId); diff --git a/src/vs/workbench/contrib/terminalContrib/chat/test/browser/terminalChatService.test.ts b/src/vs/workbench/contrib/terminalContrib/chat/test/browser/terminalChatService.test.ts index 5ed938b4586..dafc013b4ee 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/test/browser/terminalChatService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/test/browser/terminalChatService.test.ts @@ -17,7 +17,7 @@ import { ILogService, NullLogService } from '../../../../../../platform/log/comm import { ITreeSitterLibraryService } from '../../../../../../editor/common/services/treeSitter/treeSitterLibraryService.js'; import { InMemoryStorageService, IStorageService } from '../../../../../../platform/storage/common/storage.js'; import { IChatService } from '../../../../chat/common/chatService/chatService.js'; -import { IAhpTerminalCommandSource, IChatTerminalToolProgressPart, ITerminalInstance, ITerminalService } from '../../../../terminal/browser/terminal.js'; +import { IAhpTerminalCommandSource, IChatTerminalOutputSource, IChatTerminalToolProgressPart, ITerminalInstance, ITerminalService } from '../../../../terminal/browser/terminal.js'; import { TerminalChatService } from '../../browser/terminalChatService.js'; /** @@ -106,6 +106,41 @@ suite('TerminalChatService', () => { assert.strictEqual(service.getToolSessionIdForInstance(instance), 'tool-session-a'); }); + test('registerOutputSource notifies every matching progress part directly', () => { + const notifiedPartIndices: number[] = []; + const targetSessionId = 'tool-session-target'; + for (let index = 0; index < 50; index++) { + const partSessionId = index === 25 || index === 26 ? targetSessionId : `tool-session-${index}`; + store.add(service.registerProgressPart(new class extends mock() { + override readonly elementIndex = index; + override readonly contentIndex = 0; + override readonly terminalToolSessionId = partSessionId; + + override didRegisterOutputSource(terminalToolSessionId: string): void { + if (terminalToolSessionId === partSessionId) { + notifiedPartIndices.push(index); + } + } + }())); + } + const source: IChatTerminalOutputSource = { + onDidChange: Event.None, + output: 'output', + hasExited: false, + exitCode: undefined, + }; + + store.add(service.registerOutputSource(targetSessionId, source)); + + assert.deepStrictEqual({ + notifiedPartIndices, + registeredSource: service.getOutputSource(targetSessionId), + }, { + notifiedPartIndices: [25, 26], + registeredSource: source, + }); + }); + test('continueInBackground notifies every matching progress part', () => { const markedPartIndices: number[] = []; const targetSessionId = 'tool-session-target'; From 02d171319fb95e7f6db090942f5eaadba4d1a97d Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:41:58 +0200 Subject: [PATCH 046/116] perf: keep session lookups out of per-file agent feedback loops (#332708) perf: keep session lookups out of per-file feedback loops The agent feedback overlay walked every original/modified URI of the active multi-diff and did session-scoped work per file. Each candidate ended up in `ISessionsManagementService.getSession()`, which rebuilds every provider's session catalog and scans it linearly, so a Changes editor with thousands of files blocked the renderer for seconds (worst case: no feedback at all, since nothing stops the scan early). - Resolve sessions in `AgentFeedbackService` through the active session facade when it is the one asked for, plus a single-entry memo of the last lookup (hit or miss) that is dropped on any session catalog change. - Deduplicate candidates by session resource via `getFeedbackSessionCandidates` so feedback/backend work runs once per distinct session, lazily, preserving the existing early exit. Refs #332670 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentFeedbackEditorActions.ts | 15 ++-- .../browser/agentFeedbackEditorOverlay.ts | 9 +-- .../browser/agentFeedbackEditorUtils.ts | 28 +++++++ .../browser/agentFeedbackService.ts | 45 +++++++++-- .../browser/agentFeedbackEditorUtils.test.ts | 64 +++++++++++++++ .../test/browser/agentFeedbackService.test.ts | 80 ++++++++++++++++++- 6 files changed, 217 insertions(+), 24 deletions(-) create mode 100644 src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorUtils.test.ts diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts index ccb43de7308..47c0c17c397 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts @@ -18,7 +18,7 @@ import { GroupsOrder, IEditorGroupsService } from '../../../../workbench/service import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; import { AgentFeedbackState, IAgentFeedbackService } from './agentFeedbackService.js'; -import { getActiveResourceCandidates } from './agentFeedbackEditorUtils.js'; +import { getActiveResourceCandidates, getFeedbackSessionCandidates } from './agentFeedbackEditorUtils.js'; import { Menus } from '../../../browser/menus.js'; import { ICodeReviewService } from '../../codeReview/browser/codeReviewService.js'; import { getSessionEditorComments } from './sessionEditorComments.js'; @@ -53,13 +53,10 @@ abstract class AgentFeedbackEditorAction extends Action2 { ?? editorGroupsService.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE).find(g => g.activeEditorPane)?.activeEditorPane ?? editorService.visibleEditorPanes[0]; const candidates = getActiveResourceCandidates(activePane?.input); - for (const candidate of candidates) { - const sessionResource = agentFeedbackService.getFeedbackSessionResource(candidate) - ?? agentFeedbackService.getMostRecentSessionForResource(candidate); - if (!sessionResource) { - continue; - } - + const sessionCandidates = getFeedbackSessionCandidates(candidates, candidate => + agentFeedbackService.getFeedbackSessionResource(candidate) + ?? agentFeedbackService.getMostRecentSessionForResource(candidate)); + for (const { resource, sessionResource } of sessionCandidates) { const comments = getSessionEditorComments( sessionResource, agentFeedbackService.getFeedback(sessionResource), @@ -67,7 +64,7 @@ abstract class AgentFeedbackEditorAction extends Action2 { agentFeedbackService.getVisibleResolvedFeedbackIds(sessionResource), ); if (comments.length > 0) { - return this.runWithSession(accessor, sessionResource, candidate); + return this.runWithSession(accessor, sessionResource, resource); } } } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts index 8dc34964109..29c64998764 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts @@ -15,7 +15,7 @@ import { IEditorGroup, IEditorGroupsService } from '../../../../workbench/servic import { AgentEditorCommentsOverlayWidget } from '../../../../workbench/services/agentEditorComments/browser/agentEditorCommentsOverlayWidget.js'; import { IAgentFeedbackService } from './agentFeedbackService.js'; import { hasUnsubmittedAgentFeedback, hasSessionEditorComments, navigateNextFeedbackActionId, navigatePreviousFeedbackActionId, navigationBearingFakeActionId, submitFeedbackActionId } from './agentFeedbackEditorActions.js'; -import { getActiveResourceCandidates } from './agentFeedbackEditorUtils.js'; +import { getActiveResourceCandidates, getFeedbackSessionCandidates } from './agentFeedbackEditorUtils.js'; import { Menus } from '../../../browser/menus.js'; import { ICodeReviewService } from '../../codeReview/browser/codeReviewService.js'; import { EmptyFileEditorInput } from '../../editor/browser/emptyFileEditorInput.js'; @@ -93,12 +93,7 @@ export class AgentFeedbackOverlayController { const candidates = getAgentFeedbackOverlayResourceCandidates(activeInput); let navigationBearings = undefined; let acceptedFeedbackCount = 0; - for (const candidate of candidates) { - const sessionResource = agentFeedbackService.getFeedbackSessionResource(candidate); - if (!sessionResource) { - continue; - } - + for (const { sessionResource } of getFeedbackSessionCandidates(candidates, candidate => agentFeedbackService.getFeedbackSessionResource(candidate))) { const comments = getSessionEditorComments( sessionResource, agentFeedbackService.getFeedback(sessionResource), diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorUtils.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorUtils.ts index 03345201691..1f6ed34456c 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorUtils.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorUtils.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../base/common/uri.js'; +import { ResourceSet } from '../../../../base/common/map.js'; import { isEqual } from '../../../../base/common/resources.js'; import { ICodeEditor, IDiffEditor } from '../../../../editor/browser/editorBrowser.js'; import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; @@ -322,3 +323,30 @@ export function getActiveResourceCandidates(input: Parameters, resolveSessionResource: (resource: URI) => URI | undefined): Iterable { + const seenSessions = new ResourceSet(); + for (const resource of candidates) { + const sessionResource = resolveSessionResource(resource); + if (!sessionResource || seenSessions.has(sessionResource)) { + continue; + } + seenSessions.add(sessionResource); + yield { resource, sessionResource }; + } +} diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts index 8ca343a2057..26c9c39a436 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts @@ -324,6 +324,14 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe private readonly _fileToSession = new ResourceMap(); private readonly _explicitResourceScopes = new ResourceMap(); + /** + * The last {@link _resolveSession} lookup, hit or miss. Feedback resolution + * runs once per resource of the active editor, so a Changes multi-diff asks + * for the same session thousands of times in a row. A single entry is enough + * to collapse that run; it is dropped whenever the session catalog changes. + */ + private _lastResolvedSession: { readonly sessionResource: URI; readonly session: ISession | undefined } | undefined; + /** Workspace the shared new-session comments are bound to; `undefined` when there are none. */ private _boundNewSessionWorkspaceKey: string | undefined; @@ -391,6 +399,7 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe })); this._register(this._sessionsManagementService.onDidDeleteSession(session => this._forgetSession(session.resource))); + this._register(this._sessionsManagementService.onDidChangeSessions(() => this._lastResolvedSession = undefined)); } /** @@ -400,6 +409,9 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe */ private _forgetSession(sessionResource: URI): void { const key = sessionResource.toString(); + if (this._lastResolvedSession && isEqual(this._lastResolvedSession.sessionResource, sessionResource)) { + this._lastResolvedSession = undefined; + } this._sessionUpdatedOrder.delete(key); this._navigationAnchorBySession.delete(key); this._visibleResolvedFeedbackIds.delete(sessionResource); @@ -496,18 +508,39 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe } } + /** + * Resolves a session by resource, answering from the active session facade + * whenever it is the one asked for and otherwise from the last lookup. + * `ISessionsManagementService.getSession` rebuilds every provider's session + * catalog and then scans it linearly, which is far too expensive for the + * per-resource lookups this service performs while a Changes editor with + * thousands of resources is open. + */ + private _resolveSession(sessionResource: URI): ISession | undefined { + const activeSession = this._sessionsService.activeSession.get(); + if (activeSession && isEqual(activeSession.resource, sessionResource)) { + return activeSession; + } + if (this._lastResolvedSession && isEqual(this._lastResolvedSession.sessionResource, sessionResource)) { + return this._lastResolvedSession.session; + } + const session = this._sessionsManagementService.getSession(sessionResource); + this._lastResolvedSession = { sessionResource, session }; + return session; + } + getSessionForFile(resourceUri: URI): ISession | undefined { + if (!this._isFileEligibleForFeedback(resourceUri)) { + return undefined; + } const sessionResource = this._fileToSession.get(resourceUri) ?? this._sessionsService.activeSession.get()?.resource; if (!sessionResource) { return undefined; } - const session = this._sessionsManagementService.getSession(sessionResource); + const session = this._resolveSession(sessionResource); if (!session || session.status.get() === SessionStatus.Untitled) { return undefined; } - if (!this._isFileEligibleForFeedback(resourceUri)) { - return undefined; - } return session; } @@ -734,7 +767,7 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe } } - const session = this._sessionsManagementService.getSession(sessionResource); + const session = this._resolveSession(sessionResource); if (!session) { return false; } @@ -904,7 +937,7 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe } private _isAgentHostSession(sessionResource: URI): boolean { - const session = this._sessionsManagementService.getSession(sessionResource); + const session = this._resolveSession(sessionResource); return session ? isAgentHostProviderId(session.providerId) : false; } diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorUtils.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorUtils.test.ts new file mode 100644 index 00000000000..7006671f147 --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorUtils.test.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { getFeedbackSessionCandidates } from '../../browser/agentFeedbackEditorUtils.js'; + +suite('getFeedbackSessionCandidates', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const sessionA = URI.parse('test://session/a'); + const sessionB = URI.parse('test://session/b'); + + /** A Changes multi-diff contributes an original and a modified URI per file. */ + function multiDiffCandidates(fileCount: number): URI[] { + const candidates: URI[] = []; + for (let i = 0; i < fileCount; i++) { + candidates.push(URI.file(`/workspace/original/file${i}.ts`)); + candidates.push(URI.file(`/workspace/modified/file${i}.ts`)); + } + return candidates; + } + + test('yields each session once for a large multi-diff', () => { + const candidates = multiDiffCandidates(2000); + let resolveCount = 0; + const resolved = [...getFeedbackSessionCandidates(candidates, resource => { + resolveCount++; + return resource.path.includes('file0.') ? sessionA : sessionB; + })]; + + assert.deepStrictEqual({ + sessions: resolved.map(candidate => candidate.sessionResource.toString()), + resources: resolved.map(candidate => candidate.resource.path), + resolveCount, + }, { + sessions: [sessionA.toString(), sessionB.toString()], + resources: ['/workspace/original/file0.ts', '/workspace/original/file1.ts'], + resolveCount: candidates.length, + }); + }); + + test('skips candidates without a session and stops resolving once the caller breaks', () => { + const candidates = multiDiffCandidates(3); + const resolvedResources: string[] = []; + for (const { sessionResource } of getFeedbackSessionCandidates(candidates, resource => { + resolvedResources.push(resource.path); + return resource.path.includes('file0.') ? undefined : sessionA; + })) { + assert.strictEqual(sessionResource.toString(), sessionA.toString()); + break; + } + + assert.deepStrictEqual(resolvedResources, [ + '/workspace/original/file0.ts', + '/workspace/modified/file0.ts', + '/workspace/original/file1.ts', + ]); + }); +}); diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts index 9ede6f9d355..9c8eed53f84 100644 --- a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts @@ -21,7 +21,7 @@ import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IEditorService, IVisibleEditorsChangeEvent } from '../../../../../workbench/services/editor/common/editorService.js'; -import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { whenChatWidgetForSession } from '../../../chat/browser/chatWidgetUtils.js'; import { ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; @@ -60,6 +60,7 @@ suite('AgentFeedbackService - Ordering', () => { }); instantiationService.stub(ISessionsManagementService, new class extends mock() { override onDidDeleteSession = onDidDeleteSession.event; + override onDidChangeSessions = Event.None; override getSession(_resource: URI) { return undefined; } }); instantiationService.stub(ISessionsService, { activeSession: observableValue('activeSession', undefined) } as unknown as ISessionsService); @@ -368,11 +369,14 @@ suite('AgentFeedbackService - getSessionForFile', () => { let visiblePanes: any[]; let activeSessionObs: ISettableObservable; let sessions: Map; + let sessionsChangedEmitter: Emitter; let sessionS1: URI; let sessionS2: URI; let fileA: URI; let fileB: URI; + /** Number of `ISessionsManagementService.getSession` lookups performed so far. */ + let managementLookups: number; function pane(...resources: URI[]): any { // Single resource: a plain editor input with `.resource`. @@ -414,6 +418,8 @@ suite('AgentFeedbackService - getSessionForFile', () => { visiblePanes = []; activeSessionObs = observableValue('activeSession', undefined); sessions = new Map(); + sessionsChangedEmitter = store.add(new Emitter()); + managementLookups = 0; const instantiationService = store.add(new TestInstantiationService()); @@ -425,7 +431,11 @@ suite('AgentFeedbackService - getSessionForFile', () => { }); instantiationService.stub(ISessionsManagementService, new class extends mock() { override onDidDeleteSession = Event.None; - override getSession(resource: URI) { return sessions.get(resource.toString()); } + override onDidChangeSessions = sessionsChangedEmitter.event; + override getSession(resource: URI) { + managementLookups++; + return sessions.get(resource.toString()); + } }); instantiationService.stub(ISessionsService, { activeSession: activeSessionObs } as unknown as ISessionsService); @@ -608,6 +618,70 @@ suite('AgentFeedbackService - getSessionForFile', () => { assert.strictEqual(service.getSessionForFile(fileB)?.resource.toString(), sessionS1.toString()); }); + test('resolves files of the active session without a management-service lookup', () => { + setActiveSession(sessions.get(sessionS1.toString())!); + setVisibleEditors([pane(fileA)]); + + managementLookups = 0; + const trackedFile = service.getSessionForFile(fileA); + const untrackedFile = service.getSessionForFile(fileB); + + assert.deepStrictEqual({ + trackedFile: trackedFile?.resource.toString(), + untrackedFile: untrackedFile?.resource.toString(), + managementLookups, + }, { + trackedFile: sessionS1.toString(), + untrackedFile: sessionS1.toString(), + managementLookups: 0, + }); + }); + + test('looks a non-active session up once until the sessions change', () => { + setActiveSession(sessions.get(sessionS1.toString())!); + setVisibleEditors([pane(fileA)]); + setActiveSession(sessions.get(sessionS2.toString())!); + + managementLookups = 0; + service.getSessionForFile(fileA); + service.getSessionForFile(fileA); + const lookupsBeforeChange = managementLookups; + + sessionsChangedEmitter.fire({ added: [], removed: [], changed: [] }); + sessions.delete(sessionS1.toString()); + + assert.deepStrictEqual({ + lookupsBeforeChange, + sessionAfterChange: service.getSessionForFile(fileA)?.resource.toString(), + lookupsAfterChange: managementLookups - lookupsBeforeChange, + }, { + lookupsBeforeChange: 1, + sessionAfterChange: undefined, + lookupsAfterChange: 1, + }); + }); + + test('remembers that a session is unknown to the management service', () => { + setActiveSession(sessions.get(sessionS1.toString())!); + setVisibleEditors([pane(fileA)]); + setActiveSession(sessions.get(sessionS2.toString())!); + sessions.delete(sessionS1.toString()); + + managementLookups = 0; + const first = service.getSessionForFile(fileA); + const second = service.getSessionForFile(fileA); + + assert.deepStrictEqual({ + first, + second, + managementLookups, + }, { + first: undefined, + second: undefined, + managementLookups: 1, + }); + }); + test('returns undefined when the active session has Untitled status', () => { sessions.set(sessionS1.toString(), makeSession(sessionS1, SessionStatus.Untitled)); setActiveSession(sessions.get(sessionS1.toString())!); @@ -670,6 +744,7 @@ suite('AgentFeedbackService - State', () => { }); instantiationService.stub(ISessionsManagementService, new class extends mock() { override onDidDeleteSession = Event.None; + override onDidChangeSessions = Event.None; override getSession(_resource: URI) { return sessionProviderId ? { providerId: sessionProviderId, sessionId: 'session-1' } as unknown as ISession @@ -774,6 +849,7 @@ suite('AgentFeedbackService - Submit (agent host)', () => { }); instantiationService.stub(ISessionsManagementService, new class extends mock() { override onDidDeleteSession = Event.None; + override onDidChangeSessions = Event.None; override getSession(_resource: URI) { return { providerId: LOCAL_AGENT_HOST_PROVIDER_ID, sessionId: 'session-1' } as unknown as ISession; } From 069259ee71d7b429da101ce426385e0af3740606 Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Wed, 26 Aug 2026 11:42:06 +0200 Subject: [PATCH 047/116] chat: Refresh customization source after harness registration (#332299) * chat: Refresh customization source after harness registration Cache placeholder customization sources until their matching harness descriptor changes. This avoids repeated warning bursts while still replacing an empty source when a provider registers late. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: Avoid refreshes for unrelated harness changes Only rebind the customization source listener and refetch observed sections when the active source identity changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aiCustomizationItemsModel.ts | 23 ++++++++---- .../aiCustomizationItemsModel.test.ts | 35 +++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts index cb15a5c7b64..39be8a564aa 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts @@ -16,7 +16,7 @@ import { IProductService } from '../../../../../platform/product/common/productS import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { IAICustomizationWorkspaceService, AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; -import { ICustomizationHarnessService, isPluginCustomizationItem } from '../../common/customizationHarnessService.js'; +import { ICustomizationHarnessService, IHarnessDescriptor, isPluginCustomizationItem } from '../../common/customizationHarnessService.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; @@ -107,6 +107,8 @@ export class AICustomizationItemsModel extends Disposable implements IAICustomiz * present in `availableHarnesses`. */ private readonly sourceCache = this._register(new MutableDisposable()); + /** The descriptor bound to `sourceCache`'s current source, used to detect a late-registering harness. */ + private sourceDescriptor: IHarnessDescriptor | undefined; private pendingRefetchSource: IAICustomizationItemSource | undefined; private readonly refetchObservedScheduler = this._register(new RunOnceScheduler(() => { const source = this.pendingRefetchSource; @@ -171,9 +173,16 @@ export class AICustomizationItemsModel extends Disposable implements IAICustomiz // harnesses changes (a new external provider may have registered for the already- // active id), prune the source cache, and refetch any observed sections. const sourceChangeListener = this._register(new MutableDisposable()); + let currentSource: IAICustomizationItemSource | undefined; this._register(autorun(reader => { const activeSessionResource = this.harnessService.activeSessionResource.read(reader); - const source = this.getOrCreateSource(activeSessionResource); + const availableHarnesses = this.harnessService.availableHarnesses.read(reader); + const descriptor = availableHarnesses.find(harness => harness.id === getChatSessionType(activeSessionResource)); + const source = this.getOrCreateSource(activeSessionResource, descriptor); + if (source === currentSource) { + return; + } + currentSource = source; sourceChangeListener.value = source.onDidAICustomizationItemsChange(() => { this.scheduleRefetchObserved(source); }); @@ -205,7 +214,9 @@ export class AICustomizationItemsModel extends Disposable implements IAICustomiz } getActiveItemSource(): IAICustomizationItemSource { - return this.getOrCreateSource(this.harnessService.activeSessionResource.get()); + const activeSessionResource = this.harnessService.activeSessionResource.get(); + const descriptor = this.harnessService.findHarnessById(getChatSessionType(activeSessionResource)); + return this.getOrCreateSource(activeSessionResource, descriptor); } whenSectionLoaded(section: ItemsModelSection): Promise { @@ -229,13 +240,12 @@ export class AICustomizationItemsModel extends Disposable implements IAICustomiz this.refetchPluginCount(this.getActiveItemSource()); } - private getOrCreateSource(sessionResource: URI): IAICustomizationItemSource { + private getOrCreateSource(sessionResource: URI, descriptor: IHarnessDescriptor | undefined): IAICustomizationItemSource { const cached = this.sourceCache.value; - if (cached && isEqual(sessionResource, cached.sessionResource) && !(cached instanceof EmptyItemProviderItemSource)) { + if (cached && isEqual(sessionResource, cached.sessionResource) && descriptor === this.sourceDescriptor) { return cached; } const sessionType = getChatSessionType(sessionResource); - const descriptor = this.harnessService.findHarnessById(sessionType); const getItemSource = () => { if (!descriptor) { @@ -262,6 +272,7 @@ export class AICustomizationItemsModel extends Disposable implements IAICustomiz } }; const source = getItemSource(); + this.sourceDescriptor = descriptor; this.sourceCache.value = source; return source; } diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts index 9b67a1b6d09..8bf4b61183d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts @@ -215,6 +215,18 @@ suite('AICustomizationItemsModel', () => { assert.strictEqual(providerA_callCount, before + 1); }); + test('unrelated harness changes do not refetch observed sections', async () => { + const model = disposables.add(instaService.createInstance(AICustomizationItemsModel)); + model.getItems(AICustomizationManagementSection.Agents); + await timeout(0); + const before = providerA_callCount; + + availableHarnesses.set([...availableHarnesses.get(), createDescriptor('C', descriptorA.itemProvider)], undefined); + await timeout(0); + + assert.strictEqual(providerA_callCount, before); + }); + test('switching harness re-binds and refetches observed sections', async () => { const model = disposables.add(instaService.createInstance(AICustomizationItemsModel)); model.getItems(AICustomizationManagementSection.Agents); @@ -226,6 +238,29 @@ suite('AICustomizationItemsModel', () => { assert.notStrictEqual(sourceA, sourceB); }); + test('reuses an empty source until its harness is registered', async () => { + activeSessionResource.set(URI.parse('C:///session'), undefined); + const model = disposables.add(instaService.createInstance(AICustomizationItemsModel)); + model.getItems(AICustomizationManagementSection.Agents); + await model.whenSectionLoaded(AICustomizationManagementSection.Agents); + + const missingSource = model.getActiveItemSource(); + const repeatedMissingSource = model.getActiveItemSource(); + availableHarnesses.set([...availableHarnesses.get(), createDescriptor('C', descriptorA.itemProvider)], undefined); + await timeout(0); + await model.whenSectionLoaded(AICustomizationManagementSection.Agents); + + assert.deepStrictEqual({ + reusedMissingSource: repeatedMissingSource === missingSource, + replacedAfterRegistration: model.getActiveItemSource() !== missingSource, + providerCallCount: providerA_callCount, + }, { + reusedMissingSource: true, + replacedAfterRegistration: true, + providerCallCount: 1, + }); + }); + test('preserves provider-supplied plugin storage when pluginUri is omitted', async () => { providerA_items = [{ uri: URI.parse('agent-host://test-authority/plugins/my-plugin/skills/my-skill/SKILL.md'), From 3746c6426bc6f91230503b1d67c30a11fd088c70 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Wed, 26 Aug 2026 11:48:23 +0200 Subject: [PATCH 048/116] Allow real Git checkout test more time (#332709) test: allow real Git checkout test more time Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/git/test/node/localGitService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/platform/git/test/node/localGitService.test.ts b/src/vs/platform/git/test/node/localGitService.test.ts index e035dd87421..09cd357d567 100644 --- a/src/vs/platform/git/test/node/localGitService.test.ts +++ b/src/vs/platform/git/test/node/localGitService.test.ts @@ -241,5 +241,5 @@ suite('LocalGitService', () => { await assert.rejects(() => service.checkoutCommit('test-op', repoPath, pinnedCommit)); assert.strictEqual(await runGit('rev-parse', 'HEAD'), initialCommit); - }); + }).timeout(20_000); }); From 440806d2584872767318b592f314c26c0ebdc500 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Wed, 26 Aug 2026 12:27:18 +0100 Subject: [PATCH 049/116] Refactor waveform bar dimensions to match codicon specifications (#332713) refactor: update waveform bar count and dimensions to match codicon specifications Co-authored-by: mrleemurray --- .../browser/voiceModeOnboarding.ts | 9 +++---- .../speechToText/dictationOnboarding.ts | 5 ++-- .../voiceInputMode/media/voiceInputMode.css | 27 +++++++++---------- .../voiceInputModeActionViewItem.ts | 10 +++---- 4 files changed, 24 insertions(+), 27 deletions(-) diff --git a/src/vs/workbench/contrib/agentsVoice/browser/voiceModeOnboarding.ts b/src/vs/workbench/contrib/agentsVoice/browser/voiceModeOnboarding.ts index 54f5a6ae48d..93f8f9184ea 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/voiceModeOnboarding.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/voiceModeOnboarding.ts @@ -312,8 +312,8 @@ function advanceOscillation(waves: readonly MutableWave[], dt: number): void { /** * Draw the row of bars. Heights are symmetric about the centre line and follow - * the same centre-peak silhouette as the toolbar waveform, so the two read as - * the same instrument at different sizes. + * a centre-peak silhouette so the trace reads as one instrument rather than a + * strip of unrelated levels. */ function drawBars( context: CanvasRenderingContext2D, @@ -358,9 +358,8 @@ function bandFraction(position: number, waves: readonly MutableWave[]): number { if (total === 0) { return 0; } - // Centre-peak silhouette, matching the toolbar waveform: tallest in the - // middle, tapering to the ends, so the row reads as one instrument rather - // than a strip cut off at both edges. + // Centre-peak silhouette: tallest in the middle and tapering to the ends, so + // the row reads as one instrument rather than a strip cut off at both edges. const taper = Math.sin(Math.PI * Math.min(1, Math.max(0, position))); return (amplitude / total) * (0.35 + 0.65 * taper); } diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/dictationOnboarding.ts b/src/vs/workbench/contrib/chat/browser/speechToText/dictationOnboarding.ts index 97c8f0fa21b..3d3bfcbd6e5 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/dictationOnboarding.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/dictationOnboarding.ts @@ -162,9 +162,8 @@ function bandFraction(position: number, time: number): number { if (total === 0) { return 0; } - // Centre-peak silhouette, matching the toolbar waveform: tallest in the - // middle, tapering to the ends, so the row reads as one instrument rather - // than a strip cut off at both edges. + // Centre-peak silhouette: tallest in the middle and tapering to the ends, so + // the row reads as one instrument rather than a strip cut off at both edges. const taper = Math.sin(Math.PI * Math.min(1, Math.max(0, position))); return (amplitude / total) * (0.35 + 0.65 * taper); } diff --git a/src/vs/workbench/contrib/chat/browser/voiceInputMode/media/voiceInputMode.css b/src/vs/workbench/contrib/chat/browser/voiceInputMode/media/voiceInputMode.css index c1c27260dfe..16e148a5b8d 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceInputMode/media/voiceInputMode.css +++ b/src/vs/workbench/contrib/chat/browser/voiceInputMode/media/voiceInputMode.css @@ -132,30 +132,32 @@ transition: none; } -/* The Device EQ waveform lives in the voice cell and transforms per state. Thin bars = default (disconnected); thick bars = filled (connected). Its box matches the compact glyph size of the neighbouring cells so every state of the pill reads at the same optical weight. */ +/* The waveform uses the exact four-stroke silhouette and 12px box of the + * `voice-mode-compact` codicon, then transforms those strokes per state. */ .chat-voice-input-mode-bars { display: inline-flex; align-items: center; + justify-content: center; gap: 2px; - height: 12px; + width: var(--vscode-codiconFontSize-compact); + height: var(--vscode-codiconFontSize-compact); } /* Bars are strokes, not shapes — keep them at the same visual weight as the codicon glyphs in the neighbouring cells so the waveform doesn't read as bolder than the mic. */ .chat-voice-input-mode-bar { width: 1px; - border-radius: 1px; + border-radius: var(--vscode-cornerRadius-circle); background: currentColor; transform-origin: center center; transition: height 0.22s cubic-bezier(0.2, 0.9, 0.2, 1); } -/* Device EQ silhouette: symmetric center-peak (matches Device EQ.svg), scaled to the 12px glyph box. Bars stay thin in every state — connected reads via a darker color, not thicker bars. */ -.chat-voice-input-mode-bar:nth-child(1) { height: 3px; } -.chat-voice-input-mode-bar:nth-child(2) { height: 6px; } -.chat-voice-input-mode-bar:nth-child(3) { height: 9px; } -.chat-voice-input-mode-bar:nth-child(4) { height: 6px; } -.chat-voice-input-mode-bar:nth-child(5) { height: 3px; } +/* Exact stroke heights from the compact codicon. */ +.chat-voice-input-mode-bar:nth-child(1) { height: 6px; } +.chat-voice-input-mode-bar:nth-child(2) { height: 12px; } +.chat-voice-input-mode-bar:nth-child(3) { height: 8px; } +.chat-voice-input-mode-bar:nth-child(4) { height: 4px; } /* Hover while connected → preview disconnect: collapse to a short, even, "silent" row (no waveform, no motion). The height transition makes leaving the hover grow the bars smoothly back into the active waveform. */ .chat-voice-input-mode-cell.voice.on:hover .chat-voice-input-mode-bar, @@ -172,7 +174,6 @@ .monaco-workbench.monaco-enable-motion .chat-voice-input-mode-cell.voice.idle-on:not(:hover):not(.sim-hover) .chat-voice-input-mode-bar:nth-child(2) { animation-delay: -0.34s; } .monaco-workbench.monaco-enable-motion .chat-voice-input-mode-cell.voice.idle-on:not(:hover):not(.sim-hover) .chat-voice-input-mode-bar:nth-child(3) { animation-delay: -0.68s; } .monaco-workbench.monaco-enable-motion .chat-voice-input-mode-cell.voice.idle-on:not(:hover):not(.sim-hover) .chat-voice-input-mode-bar:nth-child(4) { animation-delay: -1.02s; } -.monaco-workbench.monaco-enable-motion .chat-voice-input-mode-cell.voice.idle-on:not(:hover):not(.sim-hover) .chat-voice-input-mode-bar:nth-child(5) { animation-delay: -1.36s; } /* Listening / speaking → energetic equalizer. JS overrides heights when an audio analyser is available; this is the fallback (and covers the moment before capture). */ .monaco-workbench.monaco-enable-motion .chat-voice-input-mode-cell.voice.listening:not(:hover):not(.sim-hover) .chat-voice-input-mode-bar, @@ -187,16 +188,14 @@ .monaco-workbench.monaco-enable-motion .chat-voice-input-mode-cell.voice.speaking:not(:hover):not(.sim-hover) .chat-voice-input-mode-bar:nth-child(3) { animation-delay: 0.24s; } .monaco-workbench.monaco-enable-motion .chat-voice-input-mode-cell.voice.listening:not(:hover):not(.sim-hover) .chat-voice-input-mode-bar:nth-child(4), .monaco-workbench.monaco-enable-motion .chat-voice-input-mode-cell.voice.speaking:not(:hover):not(.sim-hover) .chat-voice-input-mode-bar:nth-child(4) { animation-delay: 0.36s; } -.monaco-workbench.monaco-enable-motion .chat-voice-input-mode-cell.voice.listening:not(:hover):not(.sim-hover) .chat-voice-input-mode-bar:nth-child(5), -.monaco-workbench.monaco-enable-motion .chat-voice-input-mode-cell.voice.speaking:not(:hover):not(.sim-hover) .chat-voice-input-mode-bar:nth-child(5) { animation-delay: 0.48s; } /* Undulating idle wave: gentle centered height ripple (bars scale from their center, so they grow/shrink in place rather than moving up or down). */ @keyframes chat-voice-input-mode-wave { 0%, 100% { transform: scaleY(0.72); } - 50% { transform: scaleY(1.08); } + 50% { transform: scaleY(1); } } @keyframes chat-voice-input-mode-eq { 0%, 100% { height: 2px; } - 50% { height: 10px; } + 50% { height: 12px; } } diff --git a/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeActionViewItem.ts b/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeActionViewItem.ts index 557b2b125b3..3b68fbdfbfa 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeActionViewItem.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeActionViewItem.ts @@ -73,8 +73,8 @@ async function retargetVoiceToCurrentSession(commandService: ICommandService, co } } -/** Number of animated waveform bars shown in the voice segment. */ -const WAVEFORM_BAR_COUNT = 5; +/** Number of strokes in the 12px `voice-mode-compact` codicon. */ +const WAVEFORM_BAR_COUNT = 4; /** * Height bounds (px) of an audio-reactive waveform bar. These mirror the @@ -83,7 +83,7 @@ const WAVEFORM_BAR_COUNT = 5; * against the 12px waveform box. */ const WAVEFORM_BAR_MIN_HEIGHT = 2; -const WAVEFORM_BAR_MAX_HEIGHT = 10; +const WAVEFORM_BAR_MAX_HEIGHT = 12; /** * Menu placeholder action for the segmented voice input mode toggle. The actual UI is @@ -706,11 +706,11 @@ export class VoiceInputModeActionViewItem extends BaseActionViewItem { return; } // Respect reduced-motion: skip both the rAF audio-reactive loop and the CSS - // keyframe fallback, rendering the bars at a flat static height instead. + // keyframe fallback, leaving the compact codicon silhouette at rest. if (this.accessibilityService.isMotionReduced()) { for (const bar of this._voiceBarEls) { bar.style.animation = 'none'; - bar.style.height = `${WAVEFORM_BAR_MIN_HEIGHT}px`; + bar.style.removeProperty('height'); } return; } From 222818cc1d871f299b909604ea267e955182d2a4 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Wed, 26 Aug 2026 12:28:05 +0100 Subject: [PATCH 050/116] Add layout density options to Settings menu (#332723) * feat: add layout density options to Settings menu for Modern UI * fix: update run method to return a promise for layout density changes --------- Co-authored-by: mrleemurray --- .../modernUI/browser/modernUI.contribution.ts | 39 ++++++++ .../browser/modernUI.contribution.test.ts | 93 ++++++++++++++++++- 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts b/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts index afb2f7f6d40..3fd55793b50 100644 --- a/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts +++ b/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts @@ -4,7 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchLayoutService, LayoutSettings, ModernUIDensity } from '../../../services/layout/browser/layoutService.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import { DEFAULT_SCROLLBAR_SIZE, setGlobalDefaultScrollbarSize } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; @@ -54,6 +58,41 @@ const MODERN_UI_TABS_CLASS = 'modern-ui-tabs'; const MODERN_UI_NOTIFICATIONS_DIALOGS_CLASS = 'modern-ui-notifications-dialogs'; const MODERN_UI_UPPERCASE_VIEW_HEADERS_CLASS = 'modern-ui-uppercase-view-headers'; +const LayoutDensityMenu = new MenuId('LayoutDensityMenu'); +const layoutDensityOptions = [ + { density: ModernUIDensity.Default, title: localize2('layoutDensityDefault', "Default") }, + { density: ModernUIDensity.Compact, title: localize2('layoutDensityCompact', "Compact") }, +] as const; + +MenuRegistry.appendMenuItem(MenuId.GlobalActivity, { + title: localize('layoutDensity', "Layout Density"), + submenu: LayoutDensityMenu, + group: '2_configuration', + order: 8, + when: ContextKeyExpr.equals(`config.${LayoutSettings.MODERN_UI}`, true), +}); + +for (let index = 0; index < layoutDensityOptions.length; index++) { + const option = layoutDensityOptions[index]; + registerAction2(class extends Action2 { + constructor() { + super({ + id: `workbench.action.setLayoutDensity.${option.density}`, + title: option.title, + toggled: ContextKeyExpr.equals(`config.${LayoutSettings.MODERN_UI_DENSITY}`, option.density), + menu: { + id: LayoutDensityMenu, + order: index + 1, + }, + }); + } + + override run(accessor: ServicesAccessor): Promise { + return accessor.get(IConfigurationService).updateValue(LayoutSettings.MODERN_UI_DENSITY, option.density); + } + }); +} + /** * The fixed catalog of built-in Modern UI modules. The CSS for each module * ships with the product (imported above), and all modules are enabled together diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index 2f1a48ae62e..b9f9fb72b27 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -7,12 +7,17 @@ import assert from 'assert'; import { getWindow } from '../../../../../base/browser/dom.js'; import { Orientation } from '../../../../../base/browser/ui/sash/sash.js'; import { Pane } from '../../../../../base/browser/ui/splitview/paneview.js'; +import { DeferredPromise } from '../../../../../base/common/async.js'; import { Color } from '../../../../../base/common/color.js'; import { Emitter } from '../../../../../base/common/event.js'; import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js'; +import { isIMenuItem, isISubmenuItem, MenuId, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; +import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; +import { ContextKeyExpression, ContextKeyValue } from '../../../../../platform/contextkey/common/contextkey.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { editorBackground, Extensions as ColorRegistryExtensions, IColorRegistry, listHoverBackground, listHoverForeground, listInactiveSelectionBackground, listInactiveSelectionForeground, oneOf, opaque } from '../../../../../platform/theme/common/colorRegistry.js'; import { foreground } from '../../../../../platform/theme/common/colors/baseColors.js'; @@ -113,6 +118,92 @@ suite('ModernUIContribution', () => { const colorRegistry = Registry.as(ColorRegistryExtensions.ColorContribution); const themingRegistry = Registry.as(ThemeServiceExtensions.ThemingContribution); + test('shows layout density options in the Settings menu only when Modern UI is enabled', () => { + const parent = MenuRegistry.getMenuItems(MenuId.GlobalActivity) + .filter(isISubmenuItem) + .find(item => (typeof item.title === 'string' ? item.title : item.title.value) === 'Layout Density'); + const options = parent ? MenuRegistry.getMenuItems(parent.submenu).filter(isIMenuItem) : []; + const context = (modernUI: boolean, density: ModernUIDensity) => ({ + getValue: (key: string) => ( + key === `config.${LayoutSettings.MODERN_UI}` ? modernUI + : key === `config.${LayoutSettings.MODERN_UI_DENSITY}` ? density + : undefined + ) as T, + }); + + assert.deepStrictEqual({ + parent: parent && { + group: parent.group, + order: parent.order, + visibleWhenEnabled: parent.when?.evaluate(context(true, ModernUIDensity.Default)), + visibleWhenDisabled: parent.when?.evaluate(context(false, ModernUIDensity.Default)), + }, + options: options.map(item => ({ + title: typeof item.command.title === 'string' ? item.command.title : item.command.title.value, + checkedForDefault: getToggledExpression(item.command.toggled)?.evaluate(context(true, ModernUIDensity.Default)), + checkedForCompact: getToggledExpression(item.command.toggled)?.evaluate(context(true, ModernUIDensity.Compact)), + })), + }, { + parent: { + group: '2_configuration', + order: 8, + visibleWhenEnabled: true, + visibleWhenDisabled: false, + }, + options: [ + { title: 'Default', checkedForDefault: true, checkedForCompact: false }, + { title: 'Compact', checkedForDefault: false, checkedForCompact: true }, + ], + }); + }); + + function getToggledExpression(toggled: ContextKeyExpression | { condition: ContextKeyExpression } | undefined): ContextKeyExpression | undefined { + return toggled ? (toggled as { condition?: ContextKeyExpression }).condition ?? toggled as ContextKeyExpression : undefined; + } + + test('updates the layout density from the Settings menu', async () => { + const updates: { key: string; value: unknown }[] = []; + const updateComplete = new DeferredPromise(); + const configurationService = new class extends TestConfigurationService { + override updateValue(key: string, value: unknown): Promise { + updates.push({ key, value }); + return updateComplete.p; + } + }(); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IConfigurationService, configurationService); + const parent = MenuRegistry.getMenuItems(MenuId.GlobalActivity) + .filter(isISubmenuItem) + .find(item => (typeof item.title === 'string' ? item.title : item.title.value) === 'Layout Density'); + assert.ok(parent); + const compactOption = MenuRegistry.getMenuItems(parent.submenu) + .filter(isIMenuItem) + .find(item => item.command.id === 'workbench.action.setLayoutDensity.compact'); + assert.ok(compactOption); + const command = CommandsRegistry.getCommand(compactOption.command.id); + assert.ok(command); + + let commandCompleted = false; + const commandCompletion = Promise.resolve(instantiationService.invokeFunction(accessor => command.handler(accessor))).then(() => commandCompleted = true); + await Promise.resolve(); + const commandCompletedBeforeUpdate = commandCompleted; + updateComplete.complete(); + await commandCompletion; + + assert.deepStrictEqual({ + updates, + commandCompletedBeforeUpdate, + commandCompleted, + }, { + updates: [{ + key: LayoutSettings.MODERN_UI_DENSITY, + value: ModernUIDensity.Compact, + }], + commandCompletedBeforeUpdate: false, + commandCompleted: true, + }); + }); + test('applies startup density and relayouts when density or enablement changes', async () => { const configurationService = new TestConfigurationService({ [LayoutSettings.MODERN_UI]: true, From 588c0743e8d41bc6447ed7668a5507fdce9c29bd Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Wed, 26 Aug 2026 09:17:47 -0400 Subject: [PATCH 051/116] Add pet rendering fixtures and safety checks for chat components (#332621) * Agent Host changes for lramos15/agents/add-pet-rendering-fixtures * Address review feedback on chat pet platform fix - Dock the sub-session tip in the composer's notice stack instead of the outer stack, so the pet stands on its top edge rather than sinking 31px into it. The tip already claimed the composer's notice lane, so this needed no pet changes. - Replace the notification + getting-started tip fixture, a state the notice host cannot produce, with notification + todos which genuinely coexist. - Assert fixtures actually paint a pet sprite and keep it inside the screenshot, so a missing or cropped pet fails instead of baking into a baseline. - Restore a newline in chatPetWidget.test.ts that broke hygiene. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 83f3c563-ee39-493a-b5dd-86bde510d9e6 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 83f3c563-ee39-493a-b5dd-86bde510d9e6 --- .../chat/browser/newChatInSessionWidget.ts | 17 ++- .../contrib/chat/browser/newChatInput.ts | 11 +- .../chat/test/browser/chatView.test.ts | 14 ++- .../chat/test/browser/newChatInput.fixture.ts | 102 ++++++++++++++---- .../chat/browser/widget/chatPetWidget.ts | 40 ++++++- .../browser/widget/input/chatInputPart.ts | 34 +----- .../test/browser/widget/chatPetWidget.test.ts | 35 +++++- .../chat/chatInput.fixture.ts | 10 ++ .../chat/chatPetFixtureUtils.ts | 18 ++++ .../componentFixtures/chat/renderChatInput.ts | 45 +++++++- 10 files changed, 257 insertions(+), 69 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatInSessionWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatInSessionWidget.ts index 8be5c233563..9a7a75f3c67 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInSessionWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInSessionWidget.ts @@ -22,7 +22,7 @@ import { IChatViewOptions } from '../../../browser/parts/chatView.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { ChatInputNoticeLane } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputNoticeHost.js'; import { ChatInputNoticeVariant, ChatInputNoticeWidget } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputNoticeWidget.js'; -import { chatInputStackClass, chatInputStackSlotClass, ChatInputStackSlot, setChatInputStackSlot } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputStack.js'; +import { chatInputStackClass, ChatInputStackSlot, setChatInputStackSlot } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputStack.js'; // #region --- New Chat In Session Widget --- @@ -86,19 +86,26 @@ export class NewChatInSessionWidget extends Disposable { const chatWidgetContainer = dom.append(element, dom.$('.new-chat-widget-container')); const chatWidgetContent = dom.append(chatWidgetContainer, dom.$(`.new-chat-widget-content.${chatInputStackClass}`)); - this._renderSubSessionTip(chatWidgetContent); this._newChatInput.render(chatWidgetContent, parent); + // Rendered after the composer: the tip docks inside the composer's stack, + // so the pet stands on it rather than on the composer boundary. + this._renderSubSessionTip(); chatWidgetContainer.classList.add('revealed'); } - private _renderSubSessionTip(container: HTMLElement): void { + private _renderSubSessionTip(): void { if (this.storageService.getBoolean(STORAGE_KEY_SUB_SESSION_TIP_DISMISSED, StorageScope.PROFILE, false)) { return; } + const tipContainer = this._newChatInput.hostNoticeContainerElement; + if (!tipContainer) { + return; + } + const store = new DisposableStore(); - const tipContainer = dom.append(container, dom.$(`.sub-session-tip-container.${chatInputStackSlotClass}`)); + tipContainer.classList.add('sub-session-tip-container'); const message = localize( 'subSessionTip.message', @@ -127,7 +134,7 @@ export class NewChatInSessionWidget extends Disposable { this.storageService.store(STORAGE_KEY_SUB_SESSION_TIP_DISMISSED, true, StorageScope.PROFILE, StorageTarget.USER); // Stood down before it leaves the DOM: once detached it cannot report. setChatInputStackSlot(tipContainer, ChatInputStackSlot.Empty); - tipContainer.remove(); + // The slot belongs to the composer, so only the tip inside it goes away. this._tipDisposable.clear(); if (hadFocus) { this._newChatInput.focus(); diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index a10591cc039..f78257fb926 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -115,6 +115,7 @@ import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actio import { DictationDownloadRing, getDictationDownloadHoverMarkdown, getDictationPreparingLabel } from '../../../../workbench/contrib/chat/browser/speechToText/dictationDownloadRing.js'; import { IVoiceSessionController } from '../../../../workbench/contrib/chat/browser/voiceClient/voiceSessionController.js'; import { IChatPetWidgetService } from '../../../../workbench/contrib/chat/browser/widget/chatPetWidgetService.js'; +import { getChatPetStackPlatformTop } from '../../../../workbench/contrib/chat/browser/widget/chatPetWidget.js'; import { IVoiceModeOnboardingService } from '../../../../workbench/contrib/agentsVoice/browser/voiceModeOnboarding.js'; import { AGENTS_VOICE_ENABLED } from '../../../../workbench/contrib/agentsVoice/common/agentsVoice.js'; import { animatePromptTyping, IPromptTypingAnimation } from './promptTypingAnimation.js'; @@ -354,12 +355,18 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation /** Arbitrates which notice occupies the area above this input. */ readonly noticeHost = this._register(new ChatInputNoticeHost(() => this.focus())); private _gettingStartedTipContainer: HTMLElement | undefined; + private _hostNoticeContainer: HTMLElement | undefined; /** The canonical notice slot, directly above this input. */ get gettingStartedTipContainerElement(): HTMLElement | undefined { return this._gettingStartedTipContainer; } + /** Notice slot for the composer's host, so its content docks inside this stack. */ + get hostNoticeContainerElement(): HTMLElement | undefined { + return this._hostNoticeContainer; + } + // IHistoryNavigationWidget private readonly _onDidFocus = this._register(new Emitter()); @@ -598,6 +605,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation // Getting-started tip: the canonical notice slot, directly above and // attached to the input, matching the workbench chat input. this._gettingStartedTipContainer = dom.append(chatInputContainer, dom.$(`.chat-getting-started-tip-container.${chatInputStackSlotClass}`)); + this._hostNoticeContainer = dom.append(chatInputContainer, dom.$(`.chat-input-host-notice-container.${chatInputStackSlotClass}`)); this._promptOptionsWidget.value = this.instantiationService.createInstance(NewSessionPromptOptionsWidget, chatInputContainer, { selectOption: async (option, expectedInput, animate) => { @@ -643,7 +651,8 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation model: constObservable(undefined), hasInput: inputHasContent, inputChanged: this._editor.onDidChangeModelContent, - getPlatformTop: () => undefined, + // Stand on the notice docked above the input, not on the input itself. + getPlatformTop: () => getChatPetStackPlatformTop(chatInputContainer, inputArea), onDidChangePlatform: Event.None, }, this.options.petHostPreferred, this.onDidFocus)); this._createInputToolbar(inputArea); diff --git a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts index c311dbefefa..ac3fbba5450 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts @@ -25,7 +25,7 @@ suite('Sessions - Chat View', () => { /** Reaches the banner without standing up the widget's whole service graph. */ interface ISubSessionTipRenderer { - _renderSubSessionTip(container: HTMLElement): void; + _renderSubSessionTip(): void; } test('forwards new chat visibility to the aquarium host', () => { @@ -543,19 +543,17 @@ suite('Sessions - Chat View', () => { store.add(toDisposable(() => container.remove())); // Built through the prototype: the banner only needs its storage key, the - // input's notice host, and somewhere to keep its listeners. + // input's notice host and host slot, and somewhere to keep its listeners. const widget = Object.create(NewChatInSessionWidget.prototype) as ISubSessionTipRenderer; Object.assign(widget, { storageService: { getBoolean: () => false, store: () => { } }, - _newChatInput: { noticeHost, focus: () => { } }, + _newChatInput: { noticeHost, focus: () => { }, hostNoticeContainerElement: container }, _tipDisposable: store.add(new MutableDisposable()), }); - widget._renderSubSessionTip(container); + widget._renderSubSessionTip(); - const showing = () => { - const tip = container.querySelector('.sub-session-tip-container'); - return !!tip && isChatInputStackSlotShowing(tip); - }; + // The composer owns the slot, so the tip reports on the container itself. + const showing = () => isChatInputStackSlotShowing(container); const shownInitially = showing(); // A notification owns the space outright, so the banner must not stack with it. noticeHost.setOccupied(ChatInputNoticeLane.Notification, true, { hasFocus: () => false, focus: () => { } }); diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatInput.fixture.ts b/src/vs/sessions/contrib/chat/test/browser/newChatInput.fixture.ts index 571a62b0073..00c3983626b 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatInput.fixture.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatInput.fixture.ts @@ -28,7 +28,10 @@ import { ITtsPlaybackService } from '../../../../../workbench/contrib/chat/brows import { IMicCaptureService } from '../../../../../workbench/contrib/chat/browser/voiceClient/micCaptureService.js'; import { URI } from '../../../../../base/common/uri.js'; import { ChatInputNoticeVariant, ChatInputNoticeWidget } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputNoticeWidget.js'; -import { chatInputStackClass, chatInputStackSlotClass, ChatInputStackSlot, setChatInputStackSlot } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputStack.js'; +import { chatInputStackClass, ChatInputStackSlot, setChatInputStackSlot } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputStack.js'; +import { IChatInputNotification, ChatInputNotificationSeverity } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputNotificationService.js'; +import { IChatPetService } from '../../../../../workbench/contrib/chat/browser/chatPetService.js'; +import { configureChatPetFixtureFileRoot, FixtureChatPetService, assertChatPetInScreenshot } from '../../../../../workbench/test/browser/componentFixtures/chat/chatPetFixtureUtils.js'; // The new-session input box styling lives in these stylesheets; `style.css` // provides the `--vscode-agentsChatInput-*` theme variables and the @@ -43,8 +46,27 @@ interface NewChatInputFixtureOptions { readonly selection?: { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number }; /** Docks the sub-session tip above the composer. */ readonly subSessionTip?: boolean; + /** Docks a notification above the input, through the real notification service. */ + readonly notification?: IChatInputNotification; + /** Docks a getting-started tip in the composer's own notice slot. */ + readonly gettingStartedTip?: boolean; + /** Stands the pet on the composer. */ + readonly pet?: boolean; } +/** Tall enough for the composer, its notices, and the pet standing on top. */ +const PET_FIXTURE_HEIGHT = 400; + +const petPlatformNotification: IChatInputNotification = { + id: 'fixture.petPlatform', + severity: ChatInputNotificationSeverity.Info, + message: 'Choose how you want to use Copilot.', + description: 'Sign in to use GitHub Copilot models, or add a model with your own API key.', + actions: [], + dismissible: false, + autoDismissOnMessage: false, +}; + /** * Renders the real {@link NewChatInputWidget} inside the production DOM ancestry * (`.new-chat-in-session > .new-chat-widget-container.revealed > .new-chat-widget-content`) @@ -53,12 +75,21 @@ interface NewChatInputFixtureOptions { */ async function renderNewChatInput(context: ComponentFixtureContext, fixtureOptions: NewChatInputFixtureOptions = {}): Promise { const { container, disposableStore } = context; - const { value, selection, subSessionTip } = fixtureOptions; + const { value, selection, subSessionTip, notification, gettingStartedTip, pet } = fixtureOptions; + + // Sprite sheets are resolved against the file root. + if (pet) { + configureChatPetFixtureFileRoot(disposableStore); + } + const chatPetService = pet ? disposableStore.add(new FixtureChatPetService({ enabled: true })) : undefined; const instantiationService = createEditorServices(disposableStore, { colorTheme: context.theme, additionalServices: (reg) => { - registerChatFixtureServices(reg); + registerChatFixtureServices(reg, { notification }); + if (chatPetService) { + reg.defineInstance(IChatPetService, chatPetService); + } reg.defineInstance(IQuickInputService, new class extends mock() { override readonly onShow = Event.None; override readonly onHide = Event.None; @@ -132,7 +163,7 @@ async function renderNewChatInput(context: ComponentFixtureContext, fixtureOptio }); container.style.width = '600px'; - container.style.height = '160px'; + container.style.height = pet ? `${PET_FIXTURE_HEIGHT}px` : '160px'; container.classList.add('monaco-workbench', 'agent-sessions-workbench'); // `.new-chat-in-session` scopes the layout overrides and @@ -143,21 +174,6 @@ async function renderNewChatInput(context: ComponentFixtureContext, fixtureOptio const widgetContainer = dom.append(root, dom.$('.new-chat-widget-container.revealed')); const content = dom.append(widgetContainer, dom.$(`.new-chat-widget-content.${chatInputStackClass}`)); - // The sub-session tip, docked above the composer. The composer is a stack of - // its own, so this covers a notice reaching through a nested stack to square - // the input inside it. - if (subSessionTip) { - const tipSlot = dom.append(content, dom.$(`.sub-session-tip-container.${chatInputStackSlotClass}`)); - const tip = disposableStore.add(new ChatInputNoticeWidget({ - container: tipSlot, - variant: ChatInputNoticeVariant.Tip, - ariaLabel: 'Sub-session tip', - })); - dom.append(tip.domNode, dom.$('span.sub-session-tip-text')).textContent = - 'Start a parallel conversation to build on all the changes made in this session.'; - setChatInputStackSlot(tipSlot, ChatInputStackSlot.Docked); - } - const session = observableValue('session', undefined); const widget = disposableStore.add(instantiationService.createInstance(NewChatInputWidget, { session, @@ -169,6 +185,33 @@ async function renderNewChatInput(context: ComponentFixtureContext, fixtureOptio widget.render(content, container); + // Fills the composer's own tip slot, which production drives from ChatInputTipPresenter. + const tipSlot = widget.gettingStartedTipContainerElement; + if (gettingStartedTip && tipSlot) { + const tip = disposableStore.add(new ChatInputNoticeWidget({ + container: tipSlot, + variant: ChatInputNoticeVariant.Tip, + ariaLabel: 'Getting started tip', + })); + dom.append(tip.domNode, dom.$('span')).textContent = + 'Tip: Configure default permissions to start new sessions in Bypass Approvals or Autopilot mode.'; + setChatInputStackSlot(tipSlot, ChatInputStackSlot.Docked); + } + + // The sub-session tip, which `NewChatInSessionWidget` docks in the composer's host slot. + const hostSlot = widget.hostNoticeContainerElement; + if (subSessionTip && hostSlot) { + hostSlot.classList.add('sub-session-tip-container'); + const tip = disposableStore.add(new ChatInputNoticeWidget({ + container: hostSlot, + variant: ChatInputNoticeVariant.Tip, + ariaLabel: 'Sub-session tip', + })); + dom.append(tip.domNode, dom.$('span.sub-session-tip-text')).textContent = + 'Start a parallel conversation to build on all the changes made in this session.'; + setChatInputStackSlot(hostSlot, ChatInputStackSlot.Docked); + } + // The widget lays out its editor on the input container's `animationend`; in the // fixture there is no animation, so seed the value and lay out explicitly. await new Promise(r => setTimeout(r, 50)); @@ -183,6 +226,10 @@ async function renderNewChatInput(context: ComponentFixtureContext, fixtureOptio } } await new Promise(r => setTimeout(r, 50)); + + if (pet) { + assertChatPetInScreenshot(container); + } } export default defineThemedFixtureGroup({ path: 'sessions/chat/newInput/' }, { @@ -207,4 +254,19 @@ export default defineThemedFixtureGroup({ path: 'sessions/chat/newInput/' }, { WithSubSessionTip: defineComponentFixture({ render: context => renderNewChatInput(context, { value: 'What are you building?', subSessionTip: true }) }), -}); + + // Where the pet lands, for each notice that can dock above the input (#332570). + WithPet: defineComponentFixture({ + render: context => renderNewChatInput(context, { pet: true }), + }), + WithPetAndNotification: defineComponentFixture({ + render: context => renderNewChatInput(context, { notification: petPlatformNotification, pet: true }), + }), + WithPetAndGettingStartedTip: defineComponentFixture({ + render: context => renderNewChatInput(context, { gettingStartedTip: true, pet: true }), + }), + // The sub-session tip, docked from the composer's host slot. + WithPetAndSubSessionTip: defineComponentFixture({ + render: context => renderNewChatInput(context, { subSessionTip: true, pet: true }), + }), +}); \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts index c072ab7402a..e18f2e31b7a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts @@ -44,6 +44,9 @@ export interface IChatPetWidgetHost { readonly onDidChangePlatform: Event; } +/** The layer the pet is positioned in, spanning its host. */ +export const CHAT_PET_OVERLAY_CLASS = 'chat-pet-overlay'; + export const CHAT_PET_IDLE_SLEEP_DELAY = 20_000; export const CHAT_PET_CONFIRMATION_ATTENTION_DURATION = 2_000; export const CHAT_PET_ACHIEVEMENT_UNLOCKED_DURATION = 10_000; @@ -968,6 +971,41 @@ export function getChatPetPillPlatformTop(petCenterX: number, pillBounds: readon return undefined; } +/** Top of the topmost surface showing above the input, else the input's own top. */ +export function getChatPetStackPlatformTop(container: HTMLElement, inputContainer: HTMLElement, startAfter?: Element): number { + const inputTop = inputContainer.getBoundingClientRect().top; + let current = container; + let previousElement = startAfter; + while (true) { + const children = Array.from(current.children); + const startIndex = previousElement ? children.indexOf(previousElement) + 1 : 0; + let nestedContainer: HTMLElement | undefined; + for (let index = startIndex; index < children.length; index++) { + const child = children[index]; + // The pet's own overlay spans the host, so it is never a platform. + if (!dom.isHTMLElement(child) || child.classList.contains(CHAT_PET_OVERLAY_CLASS)) { + continue; + } + if (child === inputContainer) { + return inputTop; + } + if (child.contains(inputContainer)) { + nestedContainer = child; + break; + } + const bounds = child.getBoundingClientRect(); + if (bounds.height > 0 && bounds.top <= inputTop) { + return bounds.top; + } + } + if (!nestedContainer) { + return inputTop; + } + current = nestedContainer; + previousElement = undefined; + } +} + export function shouldPlaceChatPetSpeechBubbleLeft(state: ChatPetState | undefined, buttonRight: number, inputRight: number, scale = 1): boolean { return state === 'rendering' && buttonRight + CHAT_PET_SPEECH_BUBBLE_RIGHT_OVERHANG * scale > inputRight; } @@ -1177,7 +1215,7 @@ export class ChatPetWidget extends Disposable { this._selectedAccessory = this.chatPetService.selectedAccessory.get(); this._searchScheduler = this._register(new RunOnceScheduler(() => this._trySearch(), SEARCH_INTERVAL)); this.parent.classList.add('chat-pet-host'); - this._overlay = dom.$('.chat-pet-overlay'); + this._overlay = dom.$(`.${CHAT_PET_OVERLAY_CLASS}`); this.parent.prepend(this._overlay); this._register(toDisposable(() => { this.parent.classList.remove('chat-pet-host'); 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 dcf156f2097..c03318d61d2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -152,7 +152,7 @@ import { ChatArtifactsWidget } from '../chatArtifactsWidget.js'; import { handleTerminalCommandPaste, isTerminalCommandInput, isTerminalCommandPaste as isTerminalCommandPasteContent } from '../../chatTerminalCommandPaste.js'; import { ChatDynamicVariableModel } from '../../attachments/chatDynamicVariables.js'; import { ChatDragAndDrop } from '../chatDragAndDrop.js'; -import { getChatPetPillPlatformTop } from '../chatPetWidget.js'; +import { getChatPetPillPlatformTop, getChatPetStackPlatformTop } from '../chatPetWidget.js'; import { ChatFollowups } from './chatFollowups.js'; import { IChatInputNotificationService } from './chatInputNotificationService.js'; import { ChatGoalBannerWidget } from './chatGoalBannerWidget.js'; @@ -560,7 +560,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } getChatPetPlatformTop(petCenterX?: number): number { - const inputTop = this.inputContainer.getBoundingClientRect().top; if (petCenterX !== undefined) { const pillBounds: DOMRect[] = []; for (const provider of this._chatPetHorizontalPlatformProviders) { @@ -576,35 +575,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return pillTop; } } - let container = this.container; - let previousElement: Element | undefined = this.persistentContentContainer; - while (true) { - const children = Array.from(container.children); - const startIndex = previousElement ? children.indexOf(previousElement) + 1 : 0; - let nestedContainer: HTMLElement | undefined; - for (let index = startIndex; index < children.length; index++) { - const child = children[index]; - if (!dom.isHTMLElement(child)) { - continue; - } - if (child === this.inputContainer) { - return inputTop; - } - if (child.contains(this.inputContainer)) { - nestedContainer = child; - break; - } - const bounds = child.getBoundingClientRect(); - if (bounds.height > 0 && bounds.top <= inputTop) { - return bounds.top; - } - } - if (!nestedContainer) { - return inputTop; - } - container = nestedContainer; - previousElement = undefined; - } + // Skips the persistent content, which floats above the input part rather than sitting in the stack. + return getChatPetStackPlatformTop(this.container, this.inputContainer, this.persistentContentContainer); } readonly height = observableValue(this, 0); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts index cf6fb2f4cec..403dea090db 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts @@ -23,7 +23,7 @@ import { CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID, chatPetAchievements, ChatPetAcce import { ChatPetService, getChatPetVariant } from '../../../browser/chatPetService.js'; import { getChatPetAccessoryImageSource, hasChatPetAccessoryImageDimensions, hasChatPetBodyImageDimensions } from '../../../browser/widget/chatPetAccessoryRenderer.js'; import { getChatPetAccessoryRigFrame, getChatPetAccessoryRigPose, getChatPetAccessoryTrack, getChatPetAntennaeOcclusionBounds, getChatPetEyeAccessoryAnchor, getChatPetReducedMotionRigFrame } from '../../../browser/widget/chatPetAccessoryRig.js'; -import { CHAT_PET_ACHIEVEMENT_UNLOCKED_DURATION, CHAT_PET_CONFIRMATION_ATTENTION_DURATION, CHAT_PET_ICON_TRANSFORMATION_CHANCE, CHAT_PET_IDLE_SLEEP_DELAY, CHAT_PET_WALL_IMPACT_DURATION, CHAT_PET_WINDOW_OWNERSHIP_CHANNEL, CHAT_PET_YAPPING_CHANCE, ChatPetBlinkController, ChatPetDirectionChangeController, ChatPetFacingController, ChatPetHopController, ChatPetWidget, IChatPetWidgetHost, advanceChatPetThrow, doesChatPetStateBlink, doesChatPetStateTrackCursor, drawChatPetAchievementStar, getChatPetAnchoredHorizontalPosition, getChatPetAnimationFrame, getChatPetBaseState, getChatPetBlinkDelay, getChatPetBuddyName, getChatPetClickInteraction, getChatPetDefaultHorizontalPosition, getChatPetDragPosition, getChatPetEyeAccessoryGazeOffset, getChatPetFallDuration, getChatPetFallTarget, getChatPetFrameDurations, getChatPetGazeDirection, getChatPetHorizontalAnchor, getChatPetHorizontalPosition, getChatPetPillPlatformTop, getChatPetPlatformTop, getChatPetRelativeHorizontalPosition, getChatPetRenderedState, getChatPetRespawnFrameDurations, getChatPetRestoredHorizontalPosition, getChatPetScale, getChatPetSpeechFrameDurations, getChatPetSpriteName, getChatPetThrowLanding, getChatPetThrowRotation, getChatPetThrowVelocity, getChatPetVerticalOffset, getChatPetWallReboundVelocity, getChatPetWideSpriteHorizontalOffset, isChatPetImageSource, isChatPetKeyboardInteractionEnabled, isChatPetVisible, isChatPetWindowActive, setChatPetWideLayerOffset, shouldClaimChatPetWindowOnConstruction, shouldPlaceChatPetSpeechBubbleLeft, shouldReserveChatPetSpace, shouldSettleChatPetThrow } from '../../../browser/widget/chatPetWidget.js'; +import { CHAT_PET_ACHIEVEMENT_UNLOCKED_DURATION, CHAT_PET_CONFIRMATION_ATTENTION_DURATION, CHAT_PET_ICON_TRANSFORMATION_CHANCE, CHAT_PET_IDLE_SLEEP_DELAY, CHAT_PET_OVERLAY_CLASS, CHAT_PET_WALL_IMPACT_DURATION, CHAT_PET_WINDOW_OWNERSHIP_CHANNEL, CHAT_PET_YAPPING_CHANCE, ChatPetBlinkController, ChatPetDirectionChangeController, ChatPetFacingController, ChatPetHopController, ChatPetWidget, IChatPetWidgetHost, advanceChatPetThrow, doesChatPetStateBlink, doesChatPetStateTrackCursor, drawChatPetAchievementStar, getChatPetAnchoredHorizontalPosition, getChatPetAnimationFrame, getChatPetBaseState, getChatPetBlinkDelay, getChatPetBuddyName, getChatPetClickInteraction, getChatPetDefaultHorizontalPosition, getChatPetDragPosition, getChatPetEyeAccessoryGazeOffset, getChatPetFallDuration, getChatPetFallTarget, getChatPetFrameDurations, getChatPetGazeDirection, getChatPetHorizontalAnchor, getChatPetHorizontalPosition, getChatPetPillPlatformTop, getChatPetPlatformTop, getChatPetStackPlatformTop, getChatPetRelativeHorizontalPosition, getChatPetRenderedState, getChatPetRespawnFrameDurations, getChatPetRestoredHorizontalPosition, getChatPetScale, getChatPetSpeechFrameDurations, getChatPetSpriteName, getChatPetThrowLanding, getChatPetThrowRotation, getChatPetThrowVelocity, getChatPetVerticalOffset, getChatPetWallReboundVelocity, getChatPetWideSpriteHorizontalOffset, isChatPetImageSource, isChatPetKeyboardInteractionEnabled, isChatPetVisible, isChatPetWindowActive, setChatPetWideLayerOffset, shouldClaimChatPetWindowOnConstruction, shouldPlaceChatPetSpeechBubbleLeft, shouldReserveChatPetSpace, shouldSettleChatPetThrow } from '../../../browser/widget/chatPetWidget.js'; suite('ChatPetWidget', () => { @@ -2138,6 +2138,39 @@ suite('ChatPetWidget', () => { ]); }); + test('stands on the topmost surface showing above the input', () => { + const container = mainWindow.document.createElement('div'); + container.style.cssText = 'position:absolute;top:100px;left:0;width:200px'; + // Offset above the host, so it would win if the walk did not skip it. + const overlay = mainWindow.document.createElement('div'); + overlay.className = CHAT_PET_OVERLAY_CLASS; + overlay.style.cssText = 'position:absolute;top:-10px;left:0;width:200px;height:20px'; + const emptySlot = mainWindow.document.createElement('div'); + emptySlot.style.display = 'none'; + const notice = mainWindow.document.createElement('div'); + notice.style.height = '30px'; + const inputWrapper = mainWindow.document.createElement('div'); + inputWrapper.style.paddingTop = '6px'; + const input = mainWindow.document.createElement('div'); + input.style.height = '40px'; + inputWrapper.append(input); + container.append(overlay, emptySlot, notice, inputWrapper); + mainWindow.document.body.append(container); + disposables.add(toDisposable(() => container.remove())); + + const containerTop = container.getBoundingClientRect().top; + const dockedNotice = getChatPetStackPlatformTop(container, input) - containerTop; + const skippingLeadingContent = getChatPetStackPlatformTop(container, input, notice) - containerTop; + notice.style.display = 'none'; + const noticeStoodDown = getChatPetStackPlatformTop(container, input) - containerTop; + + assert.deepStrictEqual({ dockedNotice, skippingLeadingContent, noticeStoodDown }, { + dockedNotice: 0, + skippingLeadingContent: 36, + noticeStoodDown: 6, + }); + }); + test('uses only the pill under the pet as a raised platform', () => { const pillBounds = [ { left: 10, right: 50, top: 120, width: 40, height: 22 }, diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts index adb6e57b541..c8ea76fd58d 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts @@ -135,4 +135,14 @@ export default defineThemedFixtureGroup({ path: 'chat/input/' }, { VoiceModeListening: defineComponentFixture({ render: context => renderChatInput(context, { voiceControl: 'voiceListening' }) }), VoiceModeSpeaking: defineComponentFixture({ render: context => renderChatInput(context, { voiceControl: 'voiceSpeaking' }) }), VoiceModeDisconnect: defineComponentFixture({ render: context => renderChatInput(context, { voiceControl: 'voiceDisconnect' }) }), + + // Where the pet lands, with and without a notice docked above the input (#332570). + WithPet: defineComponentFixture({ render: context => renderChatInput(context, { pet: true }) }), + WithPetAndNotification: defineComponentFixture({ + render: context => renderChatInput(context, { pet: true, notification: sampleNotification }) + }), + // Notification and todo list are separate stack members, so they genuinely coexist. + WithPetAndNotificationAndTodos: defineComponentFixture({ + render: context => renderChatInput(context, { pet: true, notification: sampleNotification, todos: sampleTodos }) + }), }); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatPetFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatPetFixtureUtils.ts index bb50066f3e0..7f6aa6f724c 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatPetFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatPetFixtureUtils.ts @@ -113,3 +113,21 @@ export function configureChatPetFixtureFileRoot(disposableStore: DisposableStore globalThis._VSCODE_FILE_ROOT = `${mainWindow.location.origin}/src/`; disposableStore.add(toDisposable(() => globalThis._VSCODE_FILE_ROOT = previousFileRoot)); } + +/** Fails loudly when the pet is missing, unpainted or cropped, which the screenshot alone would bake in as correct. */ +export function assertChatPetInScreenshot(container: HTMLElement): void { + const pet = container.querySelector('.chat-pet-button'); + if (!pet) { + throw new Error('Chat pet fixture: the pet did not render.'); + } + // A sprite stays hidden until its image loads and passes dimension validation. + const sprite = container.querySelector('.chat-pet-sprite:not(.hidden) img.chat-pet-spritesheet'); + if (!sprite?.complete || sprite.naturalWidth === 0) { + throw new Error('Chat pet fixture: no pet sprite was painted, so the screenshot would show an empty pet.'); + } + const petBounds = pet.getBoundingClientRect(); + const bounds = container.getBoundingClientRect(); + if (petBounds.top < bounds.top || petBounds.bottom > bounds.bottom || petBounds.left < bounds.left || petBounds.right > bounds.right) { + throw new Error(`Chat pet fixture: the pet falls outside the screenshot. Pet ${JSON.stringify(petBounds)}, container ${JSON.stringify(bounds)}.`); + } +} diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/renderChatInput.ts b/src/vs/workbench/test/browser/componentFixtures/chat/renderChatInput.ts index 4fb9becbff1..1503bb3ad39 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/renderChatInput.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/renderChatInput.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from '../../../../../base/common/event.js'; -import { observableValue } from '../../../../../base/common/observable.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { Codicon } from '../../../../../base/common/codicons.js'; @@ -24,6 +24,12 @@ import { ChatAgentLocation, ChatConfiguration } from '../../../../contrib/chat/c import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../../platform/sandbox/common/settings.js'; import { ComponentFixtureContext, createEditorServices } from '../fixtureUtils.js'; import { FixtureMenuService, registerChatFixtureServices } from './chatFixtureUtils.js'; +import { IChatPetService } from '../../../../contrib/chat/browser/chatPetService.js'; +import { IChatPetWidgetService } from '../../../../contrib/chat/browser/widget/chatPetWidgetService.js'; +import { configureChatPetFixtureFileRoot, FixtureChatPetService, assertChatPetInScreenshot } from './chatPetFixtureUtils.js'; + +/** Room above the input for the pet, which stands outside it. */ +const PET_HEADROOM = 64; /** * A standalone dictation / Voice Mode control rendered in the execute toolbar, @@ -90,18 +96,29 @@ export interface ChatInputFixtureOptions { * rather than staged. */ readonly notification?: IChatInputNotification; + /** Stands the pet on the input, wired the way `ChatWidget` wires it. */ + readonly pet?: boolean; } export async function renderChatInput(context: ComponentFixtureContext, fixtureOptions: ChatInputFixtureOptions = {}): Promise { const { container, disposableStore } = context; - const { artifacts = [], editingSession, todos = [], isSessionsWindow = false, value, selection, sandboxingEnabled = false, width = 500, models = [], voiceControl, notification } = fixtureOptions; + const { artifacts = [], editingSession, todos = [], isSessionsWindow = false, value, selection, sandboxingEnabled = false, width = 500, models = [], voiceControl, notification, pet = false } = fixtureOptions; const artifactGroups: IArtifactSourceGroup[] = artifacts.length > 0 ? [{ source: { kind: 'agent' as const }, artifacts }] : []; const artifactsObs = observableValue('artifactGroups', artifactGroups); + // Sprite sheets are resolved against the file root. + if (pet) { + configureChatPetFixtureFileRoot(disposableStore); + } + const chatPetService = pet ? disposableStore.add(new FixtureChatPetService({ enabled: true })) : undefined; + const instantiationService = createEditorServices(disposableStore, { colorTheme: context.theme, additionalServices: (reg) => { registerChatFixtureServices(reg, { artifactGroups: artifactsObs, todos, notification }); + if (chatPetService) { + reg.defineInstance(IChatPetService, chatPetService); + } if (models.length > 0) { const modelsById = new Map(models.map(model => [model.identifier, model])); reg.defineInstance(ILanguageModelsService, new class extends mock() { @@ -147,6 +164,10 @@ export async function renderChatInput(context: ComponentFixtureContext, fixtureO container.style.width = `${width}px`; container.style.backgroundColor = 'var(--vscode-sideBar-background, var(--vscode-editor-background))'; container.classList.add('monaco-workbench'); + // Keeps the pet, which stands above the input, inside the screenshot. + if (pet) { + container.style.paddingTop = `${PET_HEADROOM}px`; + } const session = document.createElement('div'); session.classList.add('interactive-session'); @@ -194,9 +215,25 @@ export async function renderChatInput(context: ComponentFixtureContext, fixtureO }(); inputPart.render(session, '', mockWidget); + + if (pet) { + // The same host `ChatWidget` registers, so the platform comes from the input part. + disposableStore.add(instantiationService.invokeFunction(accessor => accessor.get(IChatPetWidgetService).register(mockWidget, { + parent: inputPart.element, + dragBounds: inputPart.inputContainerElement ?? inputPart.element, + movementBounds: session, + model: constObservable(undefined), + hasInput: constObservable(false), + inputChanged: inputPart.inputEditor.onDidChangeModelContent, + getPlatformTop: petCenterX => inputPart.getChatPetPlatformTop(petCenterX), + onDidChangePlatform: inputPart.onDidChangeChatPetHorizontalPlatforms, + }))); + } + inputPart.layout(width); await new Promise(r => setTimeout(r, 100)); inputPart.layout(width); + if (value !== undefined) { inputPart.setValue(value, true); inputPart.layout(width); @@ -232,4 +269,8 @@ export async function renderChatInput(context: ComponentFixtureContext, fixtureO (item as HTMLElement | null)?.style.setProperty('--dictation-mic-level', '0.6'); } } + + if (pet) { + assertChatPetInScreenshot(container); + } } From bc8067208bfb0324cb4033815b97bee06cf4e591 Mon Sep 17 00:00:00 2001 From: Ahmed Mahdy <44652453+abmahdy@users.noreply.github.com> Date: Wed, 26 Aug 2026 06:51:03 -0700 Subject: [PATCH 052/116] Bound concurrency of prompt file discovery reads (#331855) * Bound concurrency of prompt file discovery reads Agent, slash command and hook discovery each fanned out over every visible prompt file with an unbounded Promise.all, opening one file handle per file. Because a discovery pass can be re-triggered before the previous one settles, several passes can be in flight at once, which on installations with large plugin or skill collections exhausts the process file handle limit and fails unrelated reads with EMFILE. Route those three call sites through a Limiter, matching how discovery concurrency is already bounded elsewhere in the codebase. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Share one discovery limiter across overlapping passes A limiter created per invocation gave each discovery pass its own quota, so overlapping passes still scaled the number of simultaneous reads with the number of passes. Move the limiter to the service so all discovery reads share a single bound, and cover the overlapping-pass case in the test. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../service/promptsServiceImpl.ts | 45 +++++++++--- .../service/promptsService.test.ts | 71 ++++++++++++++++++- 2 files changed, 106 insertions(+), 10 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index f0369bb99a4..16fe3468b8d 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -34,7 +34,7 @@ import { PROMPT_LANGUAGE_ID, PromptFileSource, PromptsType, Target, getPromptsTy import { IWorkspaceInstructionFile, PromptFilesLocator } from '../utils/promptFilesLocator.js'; import { evaluateApplyToPattern, PromptFileParser, ParsedPromptFile, PromptHeaderAttributes } from '../promptFileParser.js'; import { IAgentInstructions, IAgentSource, IChatPromptSlashCommand, IConfiguredHooksInfo, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPluginPromptPath, IBuiltinPromptPath, IPromptPath, IPromptsService, IAgentSkill, IInstructionDiscoveryInfo, IInstructionDiscoveryResult, IInstructionFile, IUserPromptPath, PromptsStorage, IPromptFileContext, IPromptFileResource, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, IPromptSourceFolderResult, ICustomAgentVisibility, IAgentInstructionFile, AgentInstructionFileType, Logger, ISlashCommandDiscoveryInfo, ISlashCommandDiscoveryResult, IAgentDiscoveryInfo, IAgentDiscoveryResult, IHookDiscoveryInfo, IResolvedChatPromptSlashCommand, matchesSessionType } from './promptsService.js'; -import { Delayer, raceCancellationError } from '../../../../../../base/common/async.js'; +import { Delayer, Limiter, raceCancellationError } from '../../../../../../base/common/async.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { ChatRequestHooks, parseSubagentHooksFromYaml } from '../hookSchema.js'; import { type IParsedHookCommand } from '../../../../../../platform/agentPlugins/common/pluginParsers.js'; @@ -53,12 +53,40 @@ import { isPromptTypeBlocked, StrictPluginOnlyCustomization } from '../../custom import { isAgentPluginForceEnabledByPolicy } from '../../plugins/agentPluginEnablement.js'; import { ChatConfiguration } from '../../constants.js'; +/** + * Maximum number of prompt files to read in parallel during discovery. + * + * Discovery fans out over every visible agent, skill, prompt and hook file, so + * without a bound a single pass opens one file handle per file, which on large + * plugin or skill collections can exhaust the process file handle limit. + */ +const PROMPT_FILE_DISCOVERY_CONCURRENCY = 10; + /** * Provides prompt services. */ export class PromptsService extends Disposable implements IPromptsService { public declare readonly _serviceBrand: undefined; + /** + * Bounds how many prompt files discovery reads in parallel. + * + * Owned by the service rather than created per invocation on purpose: an + * invalidation clears the cached discovery promise without cancelling the + * computation it was tracking, so several passes can run at once. A limiter + * per invocation would give each pass its own quota and the aggregate would + * still grow with the number of passes, which is the exhaustion this bound + * exists to prevent. + */ + private readonly _discoveryLimiter = this._register(new Limiter(PROMPT_FILE_DISCOVERY_CONCURRENCY)); + + /** + * Queues a discovery file read on the shared, service-wide limiter. + */ + private queueDiscoveryRead(task: () => Promise): Promise { + return this._discoveryLimiter.queue(task) as Promise; + } + /** * Prompt files locator utility. */ @@ -537,7 +565,7 @@ export class PromptsService extends Disposable implements IPromptsService { ...enabledSkills, ]; - const parseResults = await Promise.all(slashCommandFiles.map(async promptPath => { + const parseResults = await Promise.all(slashCommandFiles.map(promptPath => this.queueDiscoveryRead(async () => { try { const parsedPromptFile = await this.parseNew(promptPath.uri, token); let rawName: string; @@ -563,7 +591,7 @@ export class PromptsService extends Disposable implements IPromptsService { } return { status: 'skipped', skipReason: 'parse-error', errorMessage: e instanceof Error ? e.message : String(e), promptPath } satisfies ISlashCommandDiscoveryResult; } - })); + }))); // Deduplicate skills that resolve to the same canonical name. This can // happen when two skill locations point at the same files, e.g. when @@ -736,7 +764,7 @@ export class PromptsService extends Disposable implements IPromptsService { const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; const defaultFolder = this.workspaceService.getWorkspace().folders[0]; - const files = await Promise.all(allAgentFiles.map(async (promptPath): Promise => { + const files = await Promise.all(allAgentFiles.map(promptPath => this.queueDiscoveryRead(async (): Promise => { const uri = promptPath.uri; const isEnabled = !disabledAgents.has(uri); @@ -778,7 +806,7 @@ export class PromptsService extends Disposable implements IPromptsService { promptPath, }; } - })); + }))); const sourceFolders = await this._collectSourceFolderDiagnostics(PromptsType.agent); return { type: PromptsType.agent, files, sourceFolders, durationInMillis: stopWatch.elapsed() }; @@ -1253,12 +1281,13 @@ export class PromptsService extends Disposable implements IPromptsService { const defaultFolder = this.workspaceService.getWorkspace().folders[0]; // Process each hook file in parallel - const fileResults = await Promise.all(hookFiles.map(async (hookFile): Promise<{ + type HookFileResult = { file?: IPromptFileDiscoveryResult; hooks?: Map; sourceUri?: URI; hasDisabledClaudeHooks?: boolean; - }> => { + }; + const fileResults = await Promise.all(hookFiles.map(hookFile => this.queueDiscoveryRead(async (): Promise => { const name = basename(hookFile.uri); // Plugins are handled separately down below because they do their own parsing+interpolation @@ -1365,7 +1394,7 @@ export class PromptsService extends Disposable implements IPromptsService { }, }; } - })); + }))); // Merge results from parallel processing const files: IPromptFileDiscoveryResult[] = []; diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 7e6964a2f8e..0ce66e2f534 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import * as sinon from 'sinon'; -import { DeferredPromise } from '../../../../../../../base/common/async.js'; +import { DeferredPromise, timeout } from '../../../../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../../base/common/event.js'; @@ -23,7 +23,7 @@ import { ModelService } from '../../../../../../../editor/common/services/modelS import { IConfigurationChangeEvent, IConfigurationOverrides, IConfigurationService, IConfigurationValue } from '../../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ExtensionIdentifier, IExtensionDescription } from '../../../../../../../platform/extensions/common/extensions.js'; -import { IFileService } from '../../../../../../../platform/files/common/files.js'; +import { IFileContent, IFileService, IReadFileOptions } from '../../../../../../../platform/files/common/files.js'; import { FileService } from '../../../../../../../platform/files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../../../../../platform/files/common/inMemoryFilesystemProvider.js'; import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; @@ -933,6 +933,73 @@ suite('PromptsService', () => { }); + test('reads agent files with bounded concurrency', async () => { + const rootFolder = '/custom-agents-concurrency'; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + const agentCount = 40; + await mockFiles(fileService, Array.from({ length: agentCount }, (_, index) => ({ + path: `${rootFolder}/.github/agents/agent${index}.agent.md`, + contents: [ + '---', + `description: 'Agent file ${index}.'`, + '---', + ] + }))); + + let inFlight = 0; + let maxInFlight = 0; + const readFile = fileService.readFile.bind(fileService); + sinon.stub(fileService, 'readFile').callsFake(async (resource: URI, options?: IReadFileOptions, token?: CancellationToken): Promise => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + try { + // Yield so that overlapping reads are observable. + await timeout(0); + return await readFile(resource, options, token); + } finally { + inFlight--; + } + }); + + const agents = await service.getCustomAgents(CancellationToken.None); + + assert.strictEqual(agents.length, agentCount, 'Must discover every agent file.'); + assert.ok(maxInFlight > 1, 'Must read agent files concurrently.'); + assert.ok( + maxInFlight < agentCount, + `Must not read all ${agentCount} agent files at once, but read ${maxInFlight} concurrently.`, + ); + + // A discovery pass can be invalidated while it is still running, which + // starts a second pass alongside the first. Both passes must share the + // same quota, otherwise the number of open files grows with the number + // of passes. + const singlePassPeak = maxInFlight; + maxInFlight = 0; + + const firstPass = service.getCustomAgents(CancellationToken.None); + const contributedAgent = URI.joinPath(rootFolderUri, '.github/agents/agent0.agent.md'); + const registered = service.registerContributedFile( + PromptsType.agent, + contributedAgent, + { identifier: new ExtensionIdentifier('test.extension'), name: 'test' } as IExtensionDescription, + undefined, + undefined, + ); + const secondPass = service.getCustomAgents(CancellationToken.None); + await Promise.all([firstPass, secondPass]); + registered.dispose(); + + assert.ok( + maxInFlight <= singlePassPeak, + `Overlapping discovery passes must share one quota, but read ${maxInFlight} concurrently versus ${singlePassPeak} for a single pass.`, + ); + }); + + test('header with handOffs', async () => { const rootFolderName = 'custom-agents-with-handoffs'; const rootFolder = `/${rootFolderName}`; From e4fac013d2f96b2d4f5d3369128e7d5c8815ed37 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:51:13 +0000 Subject: [PATCH 053/116] Fix New Chat Window crash by creating the parked chat pet host in the main window realm (#332696) * Initial plan * Create the parked chat pet host in the main window realm Co-authored-by: benibenj <44439583+benibenj@users.noreply.github.com> * Tighten the comment on the parked chat pet host Co-authored-by: benibenj <44439583+benibenj@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: benibenj <44439583+benibenj@users.noreply.github.com> --- .../browser/widget/chatPetWidgetService.ts | 8 ++-- .../widget/chatPetWidgetService.test.ts | 48 ++++++++++++++++++- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidgetService.ts b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidgetService.ts index a2acc4a5712..8777811645e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidgetService.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidgetService.ts @@ -124,7 +124,7 @@ export class ChatPetWidgetCoordinator extends Disposable { entry.active.set(true, undefined); this.windows.set(entry.windowId, { pet, - dormantHost: this.createDormantHost(entry.host), + dormantHost: this.createDormantHost(), activeHost: entry, }); } @@ -151,8 +151,10 @@ export class ChatPetWidgetCoordinator extends Disposable { } } - private createDormantHost(host: IChatPetWidgetHost): IChatPetWidgetHost { - const parent = host.parent.ownerDocument.createElement('div'); + private createDormantHost(): IChatPetWidgetHost { + // Auxiliary windows forbid `createElement` on their own document, so the + // parked host is created in the main window realm. + const parent = dom.$('div'); return { parent, dragBounds: parent, diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidgetService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidgetService.test.ts index e6755238b8a..672a8ca0bc1 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidgetService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidgetService.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import * as dom from '../../../../../../base/browser/dom.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { toDisposable } from '../../../../../../base/common/lifecycle.js'; import { constObservable, observableValue } from '../../../../../../base/common/observable.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -16,8 +17,7 @@ import { ChatPetWidgetCoordinator } from '../../../browser/widget/chatPetWidgetS suite('ChatPetWidgetService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - function createHost(): IChatPetWidgetHost { - const parent = document.createElement('div'); + function createHost(parent: HTMLElement = document.createElement('div')): IChatPetWidgetHost { return { parent, dragBounds: parent, @@ -143,4 +143,48 @@ suite('ChatPetWidgetService', () => { assert.deepStrictEqual({ active: registration.active.get(), disposed }, { active: false, disposed: true }); }); + + test('parks the pet of an auxiliary window host in the main realm', () => { + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + disposables.add(toDisposable(() => iframe.remove())); + + const auxiliaryDocument = iframe.contentDocument!; + const parent = auxiliaryDocument.createElement('div'); + auxiliaryDocument.body.appendChild(parent); + const createElement = auxiliaryDocument.createElement; + auxiliaryDocument.createElement = () => { + throw new Error('Not allowed to create elements in child window JavaScript context.'); + }; + disposables.add(toDisposable(() => auxiliaryDocument.createElement = createElement)); + + const widget = new class extends mock() { }(); + const chatWidgetService = new class extends mock() { + override lastFocusedWidget: IChatWidget | undefined = widget; + override readonly onDidChangeFocusedWidget = Event.None; + }(); + const hostHistory: IChatPetWidgetHost[] = []; + const coordinator = disposables.add(new ChatPetWidgetCoordinator(host => { + hostHistory.push(host); + return { + setHost: (nextHost: IChatPetWidgetHost) => hostHistory.push(nextHost), + dispose: () => { }, + }; + }, chatWidgetService)); + const host = createHost(parent); + const registration = coordinator.register(widget, host); + + registration.dispose(); + const dormantParent = hostHistory[1]?.parent; + + assert.deepStrictEqual({ + hostHistory: hostHistory.map(entry => entry === host), + dormantOwnerDocument: dormantParent?.ownerDocument === document, + mainRealmDormantParent: dormantParent instanceof HTMLElement, + }, { + hostHistory: [true, false], + dormantOwnerDocument: true, + mainRealmDormantParent: true, + }); + }); }); From 89dd7dc6f1af2627dbf61c486f163ca15119388f Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 26 Aug 2026 15:58:46 +0200 Subject: [PATCH 054/116] sessions: Show chats under sessions in the list (#332739) * sessions: Show chats in sessions list Expose user-facing chats beneath their owning session, with status, navigation, collapse behavior, and contextual actions. Keep side chats and subagents grouped in the header picker. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: Refine nested chat interactions Align nested chat typography and hover behavior, keep matching titles visible, and open context-menu chats in a side chat group. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: Refine nested chat navigation Use native tree twisties, add chat drag and modifier gestures, remove the tab-bar New Chat button, and keep nested chat presentation aligned with session rows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: Complete nested chat interactions Add active-chat selection, flattened keyboard navigation, side-chat tree entries, rename support, and consistent main/side opening semantics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: Polish nested chat tree interactions Contribute chat-row actions through menus, move side-opening into the sessions service, use native tree configuration for disclosure behavior, and refine parent-child connector presentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: Address nested chat review feedback Fix chat row disposal and responsive heights, preserve user tree state during unrelated updates, propagate side-open completion, and expose chat status to screen readers. Add focused regression coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: Fix sessions list component fixture Provide the main chat required by the session model so nested-chat tree derivation can render component fixtures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/browser/menus.ts | 1 + .../browser/parts/chatCompositeBar.ts | 22 - .../sessions/browser/parts/chatGroupView.ts | 4 - .../sessions/browser/parts/chatGroupsView.ts | 52 +- .../browser/parts/media/chatCompositeBar.css | 31 -- .../browser/sessionConversationGroups.ts | 3 + .../browser/sessionsChatAccessibilityHelp.ts | 4 +- .../sessions/browser/media/sessionsList.css | 125 +++++ .../sessions/browser/sessionsActions.ts | 98 +++- .../browser/sessionsTitleBarWidget.ts | 3 +- .../sessions/browser/views/sessionsList.ts | 270 +++++++++- .../sessions/browser/views/sessionsView.ts | 52 +- .../browser/views/sessionsViewActions.ts | 14 +- .../test/browser/sessionsList.test.ts | 483 +++++++++++++++++- .../browser/sessionsListContextMenu.test.ts | 94 +++- .../test/browser/sessionsListTestUtils.ts | 10 + .../sessions/browser/sessionsService.ts | 24 +- .../browser/sessionsManagementService.test.ts | 26 + .../test/common/sessionContextKeys.test.ts | 10 +- .../test/browser/chatCompositeBar.test.ts | 7 +- .../test/browser/chatGroupsView.test.ts | 91 ++-- .../browser/sessionConversationGroups.test.ts | 4 +- .../sessions/chatCompositeBar.fixture.ts | 1 - .../sessions/sessionsList.fixture.ts | 4 + 24 files changed, 1225 insertions(+), 208 deletions(-) diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index b07c58939f8..e893f52e7c4 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -51,6 +51,7 @@ export const Menus = { SessionBarToolbar: new MenuId('SessionsSessionBarToolbar'), SessionConversations: new MenuId('SessionsSessionConversations'), SessionChatTab: new MenuId('SessionsSessionChatTab'), + SessionChatItemContext: new MenuId('SessionsSessionChatItemContext'), SessionChatBackgroundContext: new MenuId('SessionsSessionChatBackgroundContext'), SessionsEditorHeaderPrimary: new MenuId('SessionsEditorHeaderPrimary'), SessionsEditorHeaderLayout: new MenuId('SessionsEditorHeaderLayout'), diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index 8b0b738a0db..d344d2cfb82 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -15,7 +15,6 @@ import { autorun, IObservable } from '../../../base/common/observable.js'; import { isLinux } from '../../../base/common/platform.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; import { Action } from '../../../base/common/actions.js'; -import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.js'; import { InputBox } from '../../../base/browser/ui/inputbox/inputBox.js'; import { defaultInputBoxStyles } from '../../../platform/theme/browser/defaultStyles.js'; import { Codicon } from '../../../base/common/codicons.js'; @@ -80,9 +79,6 @@ export interface IChatCompositeBarDelegate { /** Activate (show + focus) the given chat within this group. */ openChat(resource: URI): void; - /** Start a new chat within this group. */ - newChat(): void; - /** A chat tab drag has started for the given chat. */ onTabDragStart?(resource: URI): void; @@ -104,8 +100,6 @@ export class ChatCompositeBar extends Disposable { private readonly _tabsRow: HTMLElement; private readonly _tabsContainer: HTMLElement; private readonly _tabsScrollbar: ScrollableElement; - private readonly _newChatAction: Action; - private readonly _newChatContainer: HTMLElement; private readonly _sessionActionsContainer: HTMLElement; private readonly _sessionToolbar: MenuWorkbenchToolBar; private readonly _tabs: IChatTab[] = []; @@ -166,18 +160,6 @@ export class ChatCompositeBar extends Disposable { })); this._tabsRow.appendChild(this._tabsScrollbar.getDomNode()); - this._newChatAction = this._register(new Action( - 'sessions.chatCompositeBar.addChat', - localize('chatCompositeBar.addChat', "New Chat in This Session"), - ThemeIcon.asClassName(Codicon.add), - true, - async () => this._delegate?.newChat(), - )); - const newChatActionBar = this._register(new ActionBar(this._tabsRow)); - newChatActionBar.push(this._newChatAction, { icon: true, label: false }); - this._newChatContainer = newChatActionBar.getContainer(); - this._newChatContainer.classList.add('chat-composite-bar-new-chat'); - this._sessionActionsContainer = $('.session-chat-tabs-actions'); this._tabsRow.appendChild(this._sessionActionsContainer); const sessionToolbarContainer = $('.chat-composite-bar-toolbar'); @@ -257,10 +239,6 @@ export class ChatCompositeBar extends Disposable { const activeChatUri = delegate.activeChatResource.read(reader); const mainChatUri = delegate.mainChatResource.read(reader); this._rebuildTabs(chats, activeChatUri, mainChatUri); - const supportsMultipleChats = delegate.session.capabilities.read(reader).supportsMultipleChats; - const isQuickChat = delegate.session.isQuickChat?.read(reader) ?? false; - this._newChatContainer.classList.toggle('hidden', !supportsMultipleChats || isQuickChat); - this._newChatAction.enabled = supportsMultipleChats && !isQuickChat && !delegate.session.isArchived.read(reader); this._showSessionActions = delegate.showSessionActions.read(reader); this._sessionActionsContainer.classList.toggle('hidden', !this._showSessionActions); diff --git a/src/vs/sessions/browser/parts/chatGroupView.ts b/src/vs/sessions/browser/parts/chatGroupView.ts index d7307f3edb6..af9b5692bbb 100644 --- a/src/vs/sessions/browser/parts/chatGroupView.ts +++ b/src/vs/sessions/browser/parts/chatGroupView.ts @@ -52,9 +52,6 @@ export interface IChatGroupContext { /** Activate (show + focus) the given chat within this group. */ openChat(resource: URI): void; - /** Start a new chat within this group. */ - newChat(): void; - /** A chat tab drag has started for the given chat. */ onTabDragStart(resource: URI): void; @@ -175,7 +172,6 @@ export class ChatGroupView extends Disposable implements ISerializableView { visible: context.tabsVisible, showSessionActions: context.showSessionActions, openChat: resource => context.openChat(resource), - newChat: () => context.newChat(), onTabDragStart: resource => context.onTabDragStart(resource), onTabDragEnd: () => context.onTabDragEnd(), }; diff --git a/src/vs/sessions/browser/parts/chatGroupsView.ts b/src/vs/sessions/browser/parts/chatGroupsView.ts index cf3383c081e..232b3523396 100644 --- a/src/vs/sessions/browser/parts/chatGroupsView.ts +++ b/src/vs/sessions/browser/parts/chatGroupsView.ts @@ -303,7 +303,6 @@ export class ChatGroupsView extends Themable { tabsVisible, showSessionActions, openChat: resource => this._openChat(entry, resource), - newChat: () => this._newChat(entry).catch(onUnexpectedError), onTabDragStart: () => { }, onTabDragEnd: () => { }, }; @@ -463,7 +462,7 @@ export class ChatGroupsView extends Themable { if (source === target && source.resourceIds.get().length <= 1) { return; } - this._splitChatIntoNewGroup(resource, source, target, zone); + await this._splitChatIntoNewGroup(resource, source, target, zone); } } @@ -482,7 +481,7 @@ export class ChatGroupsView extends Themable { this._persistLayout(); } - private _splitChatIntoNewGroup(resource: URI, source: IGroupEntry, reference: IGroupEntry, zone: Exclude): void { + private async _splitChatIntoNewGroup(resource: URI, source: IGroupEntry, reference: IGroupEntry, zone: Exclude): Promise { if (!this._grid || !this._currentSessionStore || !this._session) { return; } @@ -499,7 +498,7 @@ export class ChatGroupsView extends Themable { }); this._setActiveGroup(newGroup); - this._sessionsService.openChat(this._session, resource).catch(onUnexpectedError); + await this._sessionsService.openChat(this._session, resource); this._removeEmptyGroups(); this._applyLayout(); this._persistLayout(); @@ -518,10 +517,9 @@ export class ChatGroupsView extends Themable { } /** - * Opens a chat in a group beside the active one ("open to the side"). If the - * chat is already shown in a group, that group is focused instead of creating - * a duplicate; otherwise a new group is created to the right of the active - * group and the chat is shown there. + * Opens a chat in a group beside its current group ("open to the side"). A + * chat already sharing a group is moved into a new group to its right. A chat + * already alone in its own group is focused without creating a duplicate. */ async openChatInNewGroup(resource: URI): Promise { if (!this._session || !this._grid || !this._currentSessionStore) { @@ -531,6 +529,10 @@ export class ChatGroupsView extends Themable { const existing = this._groups.find(g => g.resourceIds.get().includes(id)); if (existing) { + if (existing.resourceIds.get().length > 1) { + await this._splitChatIntoNewGroup(resource, existing, existing, 'right'); + return; + } existing.activeResourceId.set(id, undefined); this._setActiveGroup(existing); await this._sessionsService.openChat(this._session, resource); @@ -608,7 +610,7 @@ export class ChatGroupsView extends Themable { this._setActiveGroup(source); return; } - this._splitChatIntoNewGroup(resource, source, source, 'right'); + this._splitChatIntoNewGroup(resource, source, source, 'right').catch(onUnexpectedError); return; } // Not assigned yet: only open to the side when there is another chat to @@ -710,36 +712,6 @@ export class ChatGroupsView extends Themable { } } - private async _newChat(entry: IGroupEntry): Promise { - this._setActiveGroup(entry); - const session = this._session; - if (session && !session.isArchived.get()) { - const existingIds = new Set(session.visibleChatTabs.get().map(chat => chat.resource.toString())); - await this._sessionsService.openNewChatInSession(session); - if (this._session === session && this._groups.includes(entry)) { - const createdChat = session.activeChat.get(); - const createdId = createdChat.resource.toString(); - if (!existingIds.has(createdId) && session.visibleChatTabs.get().includes(createdChat)) { - transaction(tx => { - for (const group of this._groups) { - if (group !== entry && group.resourceIds.get().includes(createdId)) { - this._detachChatFromGroup(group, createdId, tx); - } - } - if (!entry.resourceIds.get().includes(createdId)) { - entry.resourceIds.set([...entry.resourceIds.get(), createdId], tx); - } - entry.activeResourceId.set(createdId, tx); - }); - this._setActiveGroup(entry); - this._removeEmptyGroups(); - this._persistLayout(); - } - entry.view.focus(); - } - } - } - focusAdjacentGroup(direction: 'previous' | 'next'): void { const activeIndex = this._activeGroup ? this._groups.indexOf(this._activeGroup) : -1; if (activeIndex < 0 || this._groups.length < 2) { @@ -755,7 +727,7 @@ export class ChatGroupsView extends Themable { const source = this._activeGroup; const resource = source?.activeResourceId.get(); if (source && resource && source.resourceIds.get().length > 1) { - this._splitChatIntoNewGroup(URI.parse(resource), source, source, direction); + this._splitChatIntoNewGroup(URI.parse(resource), source, source, direction).catch(onUnexpectedError); } } diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index b3ba42b1693..33b5089f558 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -185,37 +185,6 @@ height: 100%; } -.chat-composite-bar-new-chat { - display: flex; - align-items: center; - flex-shrink: 0; -} - -.chat-composite-bar-new-chat.hidden { - display: none; -} - -.chat-composite-bar-new-chat .action-item .action-label { - display: flex; - align-items: center; - justify-content: center; - width: var(--editor-group-tab-height, var(--vscode-spacing-size240)); - height: var(--editor-group-tab-height, var(--vscode-spacing-size240)); - padding: 0; - border-radius: var(--vscode-cornerRadius-small); - color: var(--chat-tab-inactive-foreground, currentColor); -} - -.chat-composite-bar-new-chat .action-item .action-label:hover { - background-color: var(--vscode-toolbar-hoverBackground); - color: var(--chat-tab-active-foreground); -} - -.chat-composite-bar-new-chat .action-item .action-label:focus-visible { - outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); - outline-offset: calc(-1 * var(--vscode-strokeThickness)); -} - .session-chat-tabs-actions { display: flex; align-items: center; diff --git a/src/vs/sessions/browser/sessionConversationGroups.ts b/src/vs/sessions/browser/sessionConversationGroups.ts index 4c16c3881a7..5d56c88ecc5 100644 --- a/src/vs/sessions/browser/sessionConversationGroups.ts +++ b/src/vs/sessions/browser/sessionConversationGroups.ts @@ -37,6 +37,9 @@ export function getSessionConversationStatusAriaLabel(status: SessionStatus): st /** Returns the contributed menu group for a chat in the scoped session. */ export function getSessionConversationGroupId(chat: IChat, activeChat: IChat, extUri: IExtUri): string | undefined { + if (chat.origin?.kind === ChatOriginKind.SideChat) { + return undefined; + } if (chat.origin?.kind === ChatOriginKind.Tool) { const activeChatScope = activeChat.origin?.kind === ChatOriginKind.Tool && activeChat.origin.parentChat ? activeChat.origin.parentChat diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index fc9cdd8d1a4..c4dd6f532b7 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -61,7 +61,9 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.pastedText', "Long pasted text is stored as an attached text item and replaced in the input with a numbered inline reference.")); content.push(localize('sessionsChat.pasteAsText', "To paste the clipboard as plain text, without converting it to Markdown or storing it as an attachment, invoke Paste as Text{0}.", '')); content.push(localize('sessionsChat.backgroundActivities', "Press Shift+Tab from the chat input to reach metadata and status pills above it, then press Enter or Space to activate a pill. Live browsers appear in their own pill, and background activities such as running subagents in another. A pill with more than one entry opens a picker; use the up and down arrows to navigate, Enter to open an entry, and Escape to dismiss the picker and return focus to the pill.")); - content.push(localize('sessionsChat.conversations', "When multiple chats appear as tabs in a single group, the tab row replaces the session header and includes the session actions. Side-by-side chat groups retain the session header and keep their tab rows compact. Activate New Chat at the end of a tab row to start another chat in that group.")); + content.push(localize('sessionsChat.conversations', "When multiple chats appear as tabs in a single group, the tab row replaces the session header and includes the session actions. Side-by-side chat groups retain the session header and keep their tab rows compact.")); + content.push(localize('sessionsChat.sessionsListChats', "Sessions with multiple user-facing chats show those chats, including side chats, nested beneath the session in the Sessions list. Use the arrow keys to navigate the list and Enter to open a chat. Subagent chats are omitted from this nested list.")); + content.push(localize('sessionsChat.sessionsListChatContextMenu', "Open a nested chat's context menu to rename it, open it to the side, or, when supported, permanently delete it.")); content.push(localize('sessionsChat.subagentPills', "Subagent pills in the chat transcript can be dragged to a chat group's edge to open the subagent beside the current chat. With the keyboard, focus a subagent pill and press Alt+Enter to open it beside the current chat.")); content.push(localize('sessionsChat.chatGroups', "Chats can be arranged in groups. Focus the previous group{0} or next group{1}. Split the active chat into a group to the right{2} or below{3}, or move it to the previous group{4} or next group{5}.", ``, ``, ``, ``, ``, ``)); content.push(localize('sessionsChat.closeChat', "Activate a chat tab's close button to close (hide) that chat from the tab strip without deleting it; reopen it later from the Chats menu. The session's main chat cannot be closed.")); diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index 1a42235b226..2dab9698b2c 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -30,6 +30,31 @@ display: none !important; } + .monaco-list-row .session-chat-twistie { + position: absolute; + top: var(--vscode-spacing-size80); + left: var(--vscode-spacing-size120); + width: var(--vscode-spacing-size160); + height: var(--vscode-spacing-size160); + padding-right: 0; + font-size: 16px; + opacity: 0; + pointer-events: none; + transform: none; + z-index: 1; + } + + .monaco-list-row:hover .session-chat-twistie.collapsible, + .monaco-list-row.focused .session-chat-twistie.collapsible { + opacity: 1; + pointer-events: auto; + } + + .monaco-list-row:hover[aria-expanded] .session-item .session-icon, + .monaco-list-row.focused[aria-expanded] .session-item .session-icon { + visibility: hidden; + } + .monaco-list-row.selected .session-details-row { color: unset; } @@ -121,6 +146,7 @@ flex-direction: row; height: 100%; box-sizing: border-box; + position: relative; padding: 8px 6px 8px 12px; &.archived { @@ -408,6 +434,83 @@ } } +.monaco-list-row[aria-expanded="true"] .session-item::after { + content: ''; + position: absolute; + top: var(--vscode-spacing-size160); + bottom: 0; + left: var(--vscode-spacing-size200); + border-left: var(--vscode-strokeThickness) solid var(--vscode-tree-inactiveIndentGuidesStroke); +} + +.session-chat-item { + display: flex; + align-items: center; + height: 100%; + box-sizing: border-box; + position: relative; + gap: var(--vscode-spacing-size60); + padding: var(--vscode-spacing-size60) var(--vscode-spacing-size120) var(--vscode-spacing-size60) var(--vscode-spacing-size360); + color: var(--vscode-foreground); + font-size: var(--vscode-agents-fontSize-body1); + font-weight: var(--vscode-agents-fontWeight-regular); + line-height: 17px; + + &::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: var(--vscode-spacing-size200); + border-left: var(--vscode-strokeThickness) solid var(--vscode-tree-inactiveIndentGuidesStroke); + } + + &::after { + content: ''; + position: absolute; + top: 50%; + left: var(--vscode-spacing-size200); + width: var(--vscode-spacing-size240); + border-top: var(--vscode-strokeThickness) solid var(--vscode-tree-inactiveIndentGuidesStroke); + } + + &.last-chat { + &::before { + bottom: 50%; + width: var(--vscode-spacing-size240); + border-bottom: var(--vscode-strokeThickness) solid var(--vscode-tree-inactiveIndentGuidesStroke); + border-bottom-left-radius: var(--vscode-cornerRadius-small); + } + + &::after { + display: none; + } + } + + .session-chat-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: var(--vscode-spacing-size160); + height: var(--vscode-spacing-size160); + font-size: var(--vscode-codiconFontSize-compact); + + > .monaco-pixel-spinner { + width: var(--vscode-spacing-size120); + height: var(--vscode-spacing-size120); + } + } + + .session-chat-title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + /* Show More */ .session-show-more { @@ -773,6 +876,28 @@ } } + .monaco-list-row[aria-expanded="true"] .session-item::after { + top: var(--vscode-spacing-size200); + } + + .monaco-list-row .session-chat-twistie.collapsible { + opacity: 1; + pointer-events: auto; + top: var(--vscode-spacing-size100); + left: var(--vscode-spacing-size100); + width: var(--vscode-spacing-size200); + height: var(--vscode-spacing-size200); + } + + .monaco-list-row[aria-expanded] .session-item .session-icon { + visibility: hidden; + } + + .session-chat-item { + padding: var(--vscode-spacing-size120) var(--vscode-spacing-size120) var(--vscode-spacing-size120) var(--vscode-spacing-size360); + font-size: var(--vscode-agents-fontSize-body1); + } + .session-item .session-icon { line-height: 20px; diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index bbc243d953e..a390b01b8b5 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -53,7 +53,8 @@ import { agentsNewSessionButtonBackground, agentsNewSessionButtonBorder, agentsN import { logSessionsInteraction, SessionsInteractionSource } from '../../../common/sessionsTelemetry.js'; import { NEW_SESSION_ACTION_ID } from '../../chat/common/constants.js'; import { groupSessionsForPicker } from './sessionsPicker.js'; -import { getSessionConversationActionId, getSessionConversationGroupId } from '../../../browser/sessionConversationGroups.js'; +import { getSessionConversationActionId, getSessionConversationGroupId, SESSION_CONVERSATION_SUBAGENTS_GROUP } from '../../../browser/sessionConversationGroups.js'; +import { ISessionChatItem, SessionChatItemCanDeleteContext, SessionChatItemCanRenameContext, SessionChatItemIsUntitledContext } from './views/sessionsList.js'; import './media/newSessionActionViewItem.css'; // -- Show Sessions Picker -- @@ -539,6 +540,95 @@ registerAction2(class CloseAllSessionsAction extends Action2 { // session-level commands when the tab strip is not shown. const CHAT_TAB_KEYBINDING_WEIGHT = KeybindingWeight.SessionsContrib + 10; +registerAction2(class RenameSessionListChatAction extends Action2 { + constructor() { + super({ + id: 'sessions.list.renameChat', + title: localize2('renameChat', "Rename..."), + f1: false, + menu: { + id: Menus.SessionChatItemContext, + group: '1_chat', + order: 1, + when: ContextKeyExpr.and(SessionChatItemCanRenameContext, SessionChatItemIsUntitledContext.negate()), + }, + }); + } + + override async run(accessor: ServicesAccessor, context?: ISessionChatItem): Promise { + if (!context || !getChatCapabilities(context.chat, context.session, undefined).canRename || context.chat.status.get() === SessionStatus.Untitled) { + return; + } + const quickInputService = accessor.get(IQuickInputService); + const sessionsManagementService = accessor.get(ISessionsManagementService); + const currentTitle = context.chat.title.get().trim() || localize('untitledChat', "Untitled Chat"); + const newTitle = await quickInputService.input({ + value: currentTitle, + prompt: localize('renameChat.prompt', "New chat title"), + validateInput: async value => value.trim() ? undefined : localize('renameChat.empty', "Title cannot be empty"), + }); + const trimmedTitle = newTitle?.trim(); + if (trimmedTitle && trimmedTitle !== currentTitle) { + await sessionsManagementService.renameChat(context.session, context.chat.resource, trimmedTitle); + } + } +}); + +registerAction2(class OpenSessionListChatToSideAction extends Action2 { + constructor() { + super({ + id: 'sessions.list.openChatToSide', + title: localize2('openChatToSide', "Open to the Side"), + f1: false, + menu: { + id: Menus.SessionChatItemContext, + group: '1_chat', + order: 2, + }, + }); + } + + override async run(accessor: ServicesAccessor, context?: ISessionChatItem): Promise { + if (!context) { + return; + } + const sessionsService = accessor.get(ISessionsService); + const sessionsPartService = accessor.get(ISessionsPartService); + if (!await sessionsService.canOpenSession(context.session)) { + return; + } + sessionsService.showSession(context.session.resource); + const sessionView = sessionsPartService.getSessionView(context.session.sessionId); + if (!sessionView) { + throw new Error(`Unable to open chat to the side because session view '${context.session.sessionId}' is not mounted`); + } + await sessionView.openChatToSide(context.chat.resource); + } +}); + +registerAction2(class DeleteSessionListChatAction extends Action2 { + constructor() { + super({ + id: 'sessions.list.deleteChat', + title: localize2('deleteChat', "Delete Chat"), + f1: false, + menu: { + id: Menus.SessionChatItemContext, + group: '2_delete', + order: 1, + when: SessionChatItemCanDeleteContext, + }, + }); + } + + override async run(accessor: ServicesAccessor, context?: ISessionChatItem): Promise { + if (!context || !getChatCapabilities(context.chat, context.session, undefined).canDelete) { + return; + } + await accessor.get(ISessionsManagementService).deleteChat(context.session, context.chat.resource); + } +}); + // "New Chat in This Session" starts a new chat from the session header's overflow menu. const ADD_CHAT_TO_SESSION_ACTION_ID = 'sessions.chatCompositeBar.addChat'; @@ -1313,7 +1403,7 @@ export class SessionConversationActionsContribution extends Disposable implement scopedToSession, SessionIsCreatedContext, SessionIsArchivedContext.negate(), - ContextKeyExpr.or(ContextKeyExpr.and(SessionSupportsMultipleChatsContext, SessionHasMultipleCommittedChatsContext), SessionActiveChatHasSubagentsContext), + SessionActiveChatHasSubagentsContext, ); const allChats = session.chats.read(reader); @@ -1362,7 +1452,7 @@ export class SessionConversationActionsContribution extends Disposable implement return; } const group = getSessionConversationGroupId(chat, activeChat, extUri); - if (group) { + if (group === SESSION_CONVERSATION_SUBAGENTS_GROUP) { registerOpen(chat, group, index); } }); @@ -1380,7 +1470,7 @@ MenuRegistry.appendMenuItem(Menus.SessionBarToolbar, { when: ContextKeyExpr.and( SessionIsCreatedContext, SessionIsArchivedContext.negate(), - ContextKeyExpr.or(ContextKeyExpr.and(SessionSupportsMultipleChatsContext, SessionHasMultipleCommittedChatsContext), SessionActiveChatHasSubagentsContext), + SessionActiveChatHasSubagentsContext, ), }); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index 2ac85df5a16..a990fdd8632 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -36,7 +36,6 @@ import { ISessionsService } from '../../../services/sessions/browser/sessionsSer import { BlockedSessionsList, IBlockedSessionsHeaderActionContext, registerBlockedSessionsItemActions } from './blockedSessionsList.js'; import { SessionActionFeedback } from './sessionActionFeedback.js'; import { BlockedSessionsIndicatorModel, RequiresInputKind } from './blockedSessionsIndicatorModel.js'; -import { openSessionToTheSide } from './views/sessionsView.js'; import { getSessionWorkspaceDisplayInfo, ISessionWorkspaceDisplayInfo } from '../../../browser/sessionWorkspace.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; @@ -654,7 +653,7 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { if (sideBySide) { const session = this.sessionsManagementService.getSession(resource); if (session) { - openSessionToTheSide(this.sessionsService, session, { preserveFocus }).catch(onUnexpectedError); + this.sessionsService.openSessionToSide(session, { preserveFocus }).catch(onUnexpectedError); return; } } diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 61ceecb8453..d3d6a88fcb0 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -23,6 +23,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { fromNow } from '../../../../../base/common/date.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; +import { isEqual } from '../../../../../base/common/resources.js'; import { localize } from '../../../../../nls.js'; import { MenuId, IMenuService, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; @@ -46,7 +47,7 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { ChatSessionArchiveActionWording, ChatSessionArchiveActionWordingSettingId, getChatSessionArchivedSectionLabel, getChatSessionArchiveActionWording } from '../../../../../platform/chat/common/sessionArchiveActions.js'; -import { getSessionStatusMessage, getSessionWorkspaceKind, GITHUB_REMOTE_FILE_SCHEME, ISession, ISessionWorkspace, SessionStatus, SessionWorkspaceKind } from '../../../../services/sessions/common/session.js'; +import { ChatInteractivity, ChatOriginKind, getChatCapabilities, getSessionStatusMessage, getSessionWorkspaceKind, GITHUB_REMOTE_FILE_SCHEME, IChat, isActiveSessionStatus, ISession, ISessionWorkspace, SessionStatus, SessionWorkspaceKind } from '../../../../services/sessions/common/session.js'; import { AgentSessionApprovalModel, agentSessionApprovalId, IAgentSessionApprovalInfo } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { IVoicePlaybackService } from '../../../../../workbench/contrib/chat/common/voicePlaybackService.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; @@ -82,7 +83,7 @@ import { IAgentHostFilterService } from '../../../../services/agentHostFilter/co import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { buildOpenSessionLinkUri } from '../../../../../platform/agentHost/common/openSessionLink.js'; import { LocalSelectionTransfer } from '../../../../../platform/dnd/browser/dnd.js'; -import { DraggedSessionIdentifier, SessionsDataTransfers } from '../../../../browser/dnd.js'; +import { DraggedSessionIdentifier, fillSessionChatDragData, SessionsDataTransfers } from '../../../../browser/dnd.js'; import { IDragAndDropData } from '../../../../../base/browser/dnd.js'; import { ElementsDragAndDropData, ListViewTargetSector } from '../../../../../base/browser/ui/list/listView.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; @@ -94,6 +95,7 @@ import { IAutomationService } from '../../../../../workbench/contrib/chat/common import { ICustomViewService } from '../../../../services/customView/browser/customViewService.js'; import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../automationsConstants.js'; import { Menus } from '../../../../browser/menus.js'; +import { getSessionConversationStatusAriaLabel } from '../../../../browser/sessionConversationGroups.js'; const $ = DOM.$; @@ -113,6 +115,9 @@ export const SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING = 'sessions.list.sh export const IsSessionPinnedContext = new RawContextKey('sessionItem.isPinned', false); export const SessionItemHasBranchNameContext = new RawContextKey('sessionItem.hasBranchName', false); export const SessionItemStatusContext = new RawContextKey('sessionItem.status', SessionStatus.Completed); +export const SessionChatItemCanRenameContext = new RawContextKey('sessionChatItem.canRename', false); +export const SessionChatItemCanDeleteContext = new RawContextKey('sessionChatItem.canDelete', false); +export const SessionChatItemIsUntitledContext = new RawContextKey('sessionChatItem.isUntitled', false); /** Whether the focused session item currently belongs to a user group. */ export const SessionItemInGroupContext = new RawContextKey('sessionItem.inGroup', false); export const SessionSectionTypeContext = new RawContextKey('sessionSection.type', ''); @@ -175,7 +180,33 @@ export interface ISessionPlaceholder { readonly hover?: string; } -export type SessionListItem = ISession | ISessionSection | ISessionGroupItem | ISessionShowMore | ISessionPlaceholder; +export class SessionChatItem { + constructor( + readonly session: ISession, + readonly chat: IChat, + ) { } +} + +export type ISessionChatItem = SessionChatItem; + +export type SessionListItem = ISession | SessionChatItem | ISessionSection | ISessionGroupItem | ISessionShowMore | ISessionPlaceholder; + +function isSessionChatItem(item: SessionListItem): item is ISessionChatItem { + return item instanceof SessionChatItem; +} + +function getChatTitle(chat: IChat, reader?: IReader): string { + return chat.title.read(reader).trim() || localize('untitledChat', "Untitled Chat"); +} + +function getSessionListChats(session: ISession, reader?: IReader): readonly IChat[] { + const mainChat = session.mainChat.read(reader); + return session.chats.read(reader).filter(chat => + !isEqual(chat.resource, mainChat.resource) && + chat.origin?.kind !== ChatOriginKind.Tool && + chat.interactivity.read(reader) !== ChatInteractivity.Hidden + ); +} function isSessionGroupItem(item: SessionListItem): item is ISessionGroupItem { return 'group' in item; @@ -215,7 +246,7 @@ function isSessionPlaceholder(item: SessionListItem): item is ISessionPlaceholde } function isSessionItem(item: SessionListItem): item is ISession { - return !isSessionGroupItem(item) && !isSessionSection(item) && !isSessionShowMore(item) && !isSessionPlaceholder(item); + return !isSessionChatItem(item) && !isSessionGroupItem(item) && !isSessionSection(item) && !isSessionShowMore(item) && !isSessionPlaceholder(item); } const SHOW_MORE_FOLDERS_LABEL = '__more_folders__'; @@ -235,6 +266,8 @@ class SessionsTreeDelegate implements IListVirtualDelegate { private static readonly ITEM_HEIGHT = 54; /** Quick-chat rows are single-line — see the `.session-item.quick-chat` rules in `sessionsList.css`. */ private static readonly ITEM_HEIGHT_QUICK_CHAT = 28; + private static readonly CHAT_ITEM_HEIGHT = 28; + private static readonly CHAT_ITEM_HEIGHT_PHONE = 44; /** * Phone layout uses a taller row so the inline action toolbar can * meet the 44px minimum touch target without overflowing. Sized to @@ -256,6 +289,9 @@ class SessionsTreeDelegate implements IListVirtualDelegate { ) { } getHeight(element: SessionListItem): number { + if (isSessionChatItem(element)) { + return this._isPhone() ? SessionsTreeDelegate.CHAT_ITEM_HEIGHT_PHONE : SessionsTreeDelegate.CHAT_ITEM_HEIGHT; + } if (isSessionSection(element) || isSessionGroupItem(element)) { return SessionsTreeDelegate.SECTION_HEIGHT; } @@ -291,6 +327,9 @@ class SessionsTreeDelegate implements IListVirtualDelegate { } getTemplateId(element: SessionListItem): string { + if (isSessionChatItem(element)) { + return SessionChatItemRenderer.TEMPLATE_ID; + } if (isSessionGroupItem(element)) { return SessionGroupRenderer.TEMPLATE_ID; } @@ -309,6 +348,75 @@ class SessionsTreeDelegate implements IListVirtualDelegate { //#endregion +//#region Chat Item Renderer + +interface ISessionChatItemTemplate { + readonly container: HTMLElement; + readonly statusIcon: SessionStatusIcon; + readonly title: HighlightedLabel; + readonly disposables: DisposableStore; + readonly elementDisposables: DisposableStore; +} + +class SessionChatItemRenderer implements ITreeRenderer { + static readonly TEMPLATE_ID = 'session-chat-item'; + readonly templateId = SessionChatItemRenderer.TEMPLATE_ID; + readonly rowClassName = 'session-list-inset-row'; + + constructor( + private readonly hoverService: IHoverService, + private readonly instantiationService: IInstantiationService, + ) { } + + renderTemplate(container: HTMLElement): ISessionChatItemTemplate { + const disposables = new DisposableStore(); + const elementDisposables = disposables.add(new DisposableStore()); + container.classList.add('session-chat-item'); + + const iconContainer = DOM.append(container, $('.session-chat-icon')); + iconContainer.setAttribute('aria-hidden', 'true'); + const statusIcon = disposables.add(this.instantiationService.createInstance(SessionStatusIcon, iconContainer)); + const title = disposables.add(new HighlightedLabel(DOM.append(container, $('.session-chat-title')))); + + return { container, statusIcon, title, disposables, elementDisposables }; + } + + renderElement(node: ITreeNode, _index: number, template: ISessionChatItemTemplate): void { + const element = node.element; + if (!isSessionChatItem(element)) { + return; + } + + template.elementDisposables.clear(); + const chats = getSessionListChats(element.session); + template.container.classList.toggle('last-chat', isEqual(chats.at(-1)?.resource, element.chat.resource)); + template.elementDisposables.add(autorun(reader => { + template.title.set(getChatTitle(element.chat, reader), createMatches(node.filterData)); + const status = element.chat.status.read(reader); + template.statusIcon.setStatus( + isActiveSessionStatus(status) ? status : SessionStatus.Completed, + true, + false, + undefined, + element.chat.resource, + ); + })); + template.elementDisposables.add(this.hoverService.setupDelayedHover(template.title.element, () => ({ + content: getChatTitle(element.chat), + }), { groupId: 'sessions-list' })); + } + + disposeElement(_node: ITreeNode, _index: number, template: ISessionChatItemTemplate): void { + template.elementDisposables.clear(); + } + + disposeTemplate(template: ISessionChatItemTemplate): void { + template.disposables.dispose(); + } +} + +//#endregion + //#region Session Item Renderer /** @@ -904,7 +1012,7 @@ class SessionItemRenderer implements ITreeRenderer, _index: number, template: ISessionItemTemplate): void { + disposeElement(_node: ITreeNode, _index: number, template: ISessionItemTemplate): void { template.elementDisposables.clear(); } @@ -1438,6 +1546,15 @@ class SessionsAccessibilityProvider { } getAriaLabel(element: SessionListItem): string | IObservable | null { + if (isSessionChatItem(element)) { + return derived(this, reader => localize( + 'sessionChatItemAria', + "{0}, chat, updated {1}, {2}", + getChatTitle(element.chat, reader), + fromNow(element.chat.updatedAt.read(reader), true), + getSessionConversationStatusAriaLabel(element.chat.status.read(reader)), + )); + } if (isSessionGroupItem(element)) { return `${element.group.name}, ${element.sessions.length}`; } @@ -1587,10 +1704,17 @@ class SessionsListDragAndDrop extends Disposable implements ITreeDragAndDrop; + onChatOpen?(session: ISession, chat: IChat, preserveFocus: boolean, sideBySide: boolean): void; } /** @@ -1955,6 +2086,8 @@ export class SessionsList extends Disposable implements ISessionsList { private readonly listContainer: HTMLElement; private readonly tree: WorkbenchObjectTree; private sessions: ISession[] = []; + private readonly sessionChatsObserver = this._register(new MutableDisposable()); + private readonly activeSessionUpdate = this._register(new MutableDisposable()); private readonly automationSessions = observableValue(this, []); private visible = true; private readonly excludedSessionTypes: Set; @@ -2103,6 +2236,7 @@ export class SessionsList extends Disposable implements ISessionsList { const showMoreRenderer = new SessionShowMoreRenderer(); const placeholderRenderer = new SessionPlaceholderRenderer(hoverService); + const chatRenderer = new SessionChatItemRenderer(hoverService, instantiationService); const selectHeader = (element: ISessionSection | ISessionGroupItem, event: MouseEvent) => { this.tree.setFocus([element], event); this.tree.setSelection([element], event); @@ -2129,6 +2263,7 @@ export class SessionsList extends Disposable implements ISessionsList { delegate, [ sessionRenderer, + chatRenderer, sectionRenderer, groupRenderer, showMoreRenderer, @@ -2165,6 +2300,9 @@ export class SessionsList extends Disposable implements ISessionsList { if (isSessionPlaceholder(element)) { return `placeholder:${element.sectionId}`; } + if (isSessionChatItem(element)) { + return `chat:${element.session.sessionId}:${element.chat.resource.toString()}`; + } return element.resource.toString(); }, getGroupId: (element: SessionListItem) => { @@ -2180,6 +2318,9 @@ export class SessionsList extends Disposable implements ISessionsList { if (isSessionPlaceholder(element)) { return NotSelectableGroupId; } + if (isSessionChatItem(element)) { + return 3; + } // Use a distinct group for archived (done) sessions so that // multi-selection cannot span the workspace and done sections. return element.isArchived.get() ? 2 : 1; @@ -2187,7 +2328,7 @@ export class SessionsList extends Disposable implements ISessionsList { }, horizontalScrolling: false, multipleSelectionSupport: true, - indent: 0, + expandOnlyOnTwistieClick: element => isSessionItem(element), findWidgetEnabled: true, defaultFindMode: TreeFindMode.Filter, findWidgetContainer: this.options.findWidgetContainer, @@ -2212,14 +2353,20 @@ export class SessionsList extends Disposable implements ISessionsList { if (isSessionPlaceholder(element)) { return element.label; } + if (isSessionChatItem(element)) { + return getChatTitle(element.chat); + } return element.title.get(); } }, overrideStyles: this.options.overrideStyles, renderIndentGuides: RenderIndentGuides.None, - twistieAdditionalCssClass: () => 'force-no-twistie', + twistieAdditionalCssClass: element => isSessionItem(element) && getSessionListChats(element).length > 0 + ? 'session-chat-twistie' + : 'force-no-twistie', } )); + this.tree.updateOptions({ indent: 0, defaultIndent: 0, expandOnDoubleClick: false }); this._register(this.tree.onDidOpen(async e => { const element = e.element; @@ -2242,6 +2389,20 @@ export class SessionsList extends Disposable implements ISessionsList { if (isSessionPlaceholder(element)) { return; } + if (isSessionChatItem(element)) { + if (this.options.canOpenSession && !(await this.options.canOpenSession(element.session))) { + return; + } + this.markRead(element.session); + const isLeftClick = DOM.isMouseEvent(e.browserEvent) && e.browserEvent.button === 0; + const preserveFocus = isLeftClick ? false : (e.editorOptions.preserveFocus ?? false); + if (this.options.onChatOpen) { + this.options.onChatOpen(element.session, element.chat, preserveFocus, e.sideBySide); + } else { + this._sessionsService.openChat(element.session, element.chat.resource, { preserveFocus }).catch(onUnexpectedError); + } + return; + } if (isSessionSection(element) && element.id === AUTOMATIONS_SECTION_ID) { this.tree.setSelection([]); this.commandService.executeCommand('sessionsView.manageAutomations'); @@ -2296,11 +2457,15 @@ export class SessionsList extends Disposable implements ISessionsList { if (!e.affectsSome(phoneKeys)) { return; } - for (const session of this.sessions) { - if (this.tree.hasElement(session)) { - this.tree.updateElementHeight(session, delegate.getHeight(session)); + const updateNodeHeights = (node: ITreeNode): void => { + if (node.element && (isSessionItem(node.element) || isSessionChatItem(node.element))) { + this.tree.updateElementHeight(node.element, delegate.getHeight(node.element)); } - } + for (const child of node.children) { + updateNodeHeights(child); + } + }; + updateNodeHeights(this.tree.getNode()); })); this._register(this.tree.onContextMenu(e => this.onContextMenu(e))); @@ -2394,10 +2559,14 @@ export class SessionsList extends Disposable implements ISessionsList { // Re-render when the active session changes. this._register(autorun(reader => { - this._sessionsService.activeSession.read(reader); - if (this.visible) { - this.update(); - } + const activeSession = this._sessionsService.activeSession.read(reader); + activeSession?.activeChat.read(reader); + this.activeSessionUpdate.value = DOM.scheduleAtNextAnimationFrame(DOM.getWindow(this.listContainer), () => { + if (this.visible) { + this.update(); + this.syncActiveChatSelection(activeSession); + } + }); })); // Resolve the per-group session limit from the experiment service and @@ -2412,6 +2581,7 @@ export class SessionsList extends Disposable implements ISessionsList { })); this.refresh(); + this.syncActiveChatSelection(this._sessionsService.activeSession.get()); } /** @@ -2434,6 +2604,16 @@ export class SessionsList extends Disposable implements ISessionsList { refresh(): void { this.sessions = this._sessionsManagementService.getSessions(); + let initialized = false; + this.sessionChatsObserver.value = autorun(reader => { + for (const session of this.sessions) { + getSessionListChats(session, reader); + } + if (initialized && this.visible) { + this.update(); + } + initialized = true; + }); this.automationSessions.set(this.sessions, undefined); for (const session of this.sessions) { this._sessionsListModelService.migrateLegacyReadState(session); @@ -2584,7 +2764,17 @@ export class SessionsList extends Disposable implements ISessionsList { const sessionGroupLimit = this.sessionGroupLimit.get(); const toSessionChildren = (sessions: readonly ISession[]): IObjectTreeElement[] => - sessions.map(session => ({ element: session as SessionListItem })); + sessions.map(session => { + const chats = getSessionListChats(session); + return { + element: session as SessionListItem, + collapsible: chats.length > 0, + collapsed: ObjectTreeElementCollapseState.PreserveOrExpanded, + children: chats.length > 0 + ? chats.map(chat => ({ element: new SessionChatItem(session, chat) })) + : undefined, + }; + }); const renderSessionChildren = (sessions: readonly ISession[], sectionId: string, sectionLabel: string, enabled: boolean): IObjectTreeElement[] => { const limited = limitSessionsForList(sessions, sessionGroupLimit, { @@ -2764,6 +2954,27 @@ export class SessionsList extends Disposable implements ISessionsList { this._onDidUpdate.fire(); } + private syncActiveChatSelection(activeSession: IActiveSession | undefined): void { + if (!activeSession) { + return; + } + const session = this.sessions.find(candidate => candidate.sessionId === activeSession.sessionId); + if (!session || !this.tree.hasElement(session)) { + return; + } + const activeChat = activeSession.activeChat.get(); + const chatItem = this.tree.getNode(session).children + .map(node => node.element) + .find(element => !!element && isSessionChatItem(element) && this.uriIdentityService.extUri.isEqual(element.chat.resource, activeChat.resource)); + if (!chatItem || !isSessionChatItem(chatItem)) { + this.tree.setSelection([session]); + return; + } + this.tree.expand(session); + this.tree.reveal(chatItem, 0.5); + this.tree.setSelection([chatItem]); + } + getVisibleSessions(): readonly ISession[] { // Derive the visible session list from the tree model so that index-based // navigation matches what the user actually sees: this respects collapsed @@ -3081,6 +3292,10 @@ export class SessionsList extends Disposable implements ISessionsList { private onContextMenu(e: ITreeContextMenuEvent): void { const element = e.element; + if (element && isSessionChatItem(element)) { + this.showChatContextMenu(element, e.anchor); + return; + } if (!element || isSessionSection(element) || isSessionShowMore(element) || isSessionPlaceholder(element)) { this.showCreateGroupContextMenu(e.anchor); return; @@ -3147,6 +3362,27 @@ export class SessionsList extends Disposable implements ISessionsList { }); } + private showChatContextMenu(element: ISessionChatItem, anchor: ITreeContextMenuEvent['anchor']): void { + const capabilities = getChatCapabilities(element.chat, element.session, undefined); + const contextKeyService = this.contextKeyService.createOverlay([ + [SessionChatItemCanRenameContext.key, capabilities.canRename], + [SessionChatItemCanDeleteContext.key, capabilities.canDelete], + [SessionChatItemIsUntitledContext.key, element.chat.status.get() === SessionStatus.Untitled], + ]); + const menu = this.menuService.createMenu(Menus.SessionChatItemContext, contextKeyService); + const actions = Separator.join(...menu.getActions({ arg: element, shouldForwardArgs: true }).map(([, groupActions]) => groupActions)); + if (actions.length === 0) { + menu.dispose(); + return; + } + this.contextMenuService.showContextMenu({ + getActions: () => actions, + getAnchor: () => anchor, + getKeyBinding: action => this.keybindingService.lookupKeybinding(action.id) ?? undefined, + onHide: () => menu.dispose(), + }); + } + /** * Build the group-related context menu actions for the given session(s): * "Create Group", an "Add to Group"/"Move to Group" submenu listing the diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts index e2ccbe25493..ba896bf7685 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts @@ -28,7 +28,7 @@ import { ChatSessionArchiveActionWordingSettingId, getChatSessionArchivedSection import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { localize } from '../../../../../nls.js'; import { SessionsList, SessionsGrouping, SessionsSorting } from './sessionsList.js'; -import { ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { SessionStatus } from '../../../../services/sessions/common/session.js'; import { AICustomizationShortcutsWidget } from '../aiCustomizationShortcutsWidget.js'; import { AgentHostShortcutsWidget } from '../agentHostShortcutsWidget.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; @@ -39,6 +39,7 @@ import { IWorkbenchLayoutService, Parts } from '../../../../../workbench/service import { PANEL_SECTION_BORDER } from '../../../../../workbench/common/theme.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { ISessionsPartService } from '../../../../services/sessions/browser/sessionsPartService.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; import { Menus } from '../../../../browser/menus.js'; import { MobileSessionFilterChips } from '../../../../browser/parts/mobile/mobileSessionFilterChips.js'; @@ -53,21 +54,6 @@ const SORTING_STORAGE_KEY = 'sessionsViewPane.sorting'; const CUSTOMIZATIONS_MIN_HEIGHT = 129; const SESSIONS_SECTION_MIN_HEIGHT = 120; -/** - * Place the given session in the sessions grid to the right of the last - * currently-visible session (as a non-sticky entry) and make it active. If - * the session is already the last visible one, this is a no-op aside from - * activation. - */ -export async function openSessionToTheSide(sessionsService: ISessionsService, session: ISession, options?: { preserveFocus?: boolean }): Promise { - const visible = sessionsService.visibleSessions.get(); - const lastVisible = visible[visible.length - 1]; - if (lastVisible && lastVisible.sessionId !== session.sessionId) { - sessionsService.insertAt(session, lastVisible.sessionId, 'right'); - } - await sessionsService.openSession(session.resource, options); -} - export const SessionsViewFilterSubMenu = new MenuId('SessionsViewPaneFilterSubMenu'); export const SessionsViewFilterOptionsSubMenu = new MenuId('SessionsViewPaneFilterOptionsSubMenu'); export const SessionsViewGroupingContext = new RawContextKey('sessionsViewPane.grouping', SessionsGrouping.Workspace); @@ -110,6 +96,7 @@ export class SessionsView extends ViewPane { @IHoverService hoverService: IHoverService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @ISessionsService private readonly sessionsService: ISessionsService, + @ISessionsPartService private readonly sessionsPartService: ISessionsPartService, @IHostService private readonly hostService: IHostService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IStorageService private readonly storageService: IStorageService, @@ -220,17 +207,38 @@ export class SessionsView extends ViewPane { this.layoutService.setPartHidden(true, Parts.SIDEBAR_PART); } }; + const session = this.sessionsManagementService.getSession(resource); + if (!session) { + onUnexpectedError(new Error(`Unable to open session because '${resource.toString()}' is not available`)); + return; + } + const mainChat = session.mainChat.get(); if (sideBySide) { // Alt-click: open the session to the right of the last visible session in the grid. - const session = this.sessionsManagementService.getSession(resource); - if (session) { - openSessionToTheSide(this.sessionsService, session, { preserveFocus }).then(onOpened).catch(onUnexpectedError); - return; - } + this.sessionsService.openSessionToSide(session, { preserveFocus, chatResource: mainChat.resource }).then(onOpened).catch(onUnexpectedError); + return; } - this.sessionsService.openSession(resource, { preserveFocus }).then(onOpened).catch(onUnexpectedError); + this.sessionsService.openChat(session, mainChat.resource, { preserveFocus }).then(onOpened).catch(onUnexpectedError); }, canOpenSession: session => this.sessionsService.canOpenSession(session), + onChatOpen: (session, chat, preserveFocus, sideBySide) => { + const onOpened = () => { + if (isWeb && isPhoneLayout(this.layoutService)) { + this.layoutService.setPartHidden(true, Parts.SIDEBAR_PART); + } + }; + if (sideBySide) { + this.sessionsService.showSession(session.resource, { preserveFocus }); + const sessionView = this.sessionsPartService.getSessionView(session.sessionId); + if (!sessionView) { + onUnexpectedError(new Error(`Unable to open chat to the side because session view '${session.sessionId}' is not mounted`)); + return; + } + sessionView.openChatToSide(chat.resource).then(onOpened).catch(onUnexpectedError); + return; + } + this.sessionsService.openChat(session, chat.resource, { preserveFocus }).then(onOpened).catch(onUnexpectedError); + }, })); this._register(this.onDidChangeBodyVisibility(visible => sessionsControl.setVisible(visible))); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index e4fab13d289..5fdfdaa424c 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -27,7 +27,7 @@ import { SessionSupportsDeleteContext, SessionSupportsRenameContext, IsNewChatSe import { SessionItemToolbarMenuId, SessionItemContextMenuId, SessionSectionToolbarMenuId, SessionGroupToolbarMenuId, SessionSectionTypeContext, SessionSectionHasNonCloudRepositoryContext, SessionGroupHasVisibleSessionsContext, SessionGroupIsEmptyContext, IsSessionPinnedContext, SessionsGrouping, SessionsSorting, ISessionSection, ISessionGroupItem, NEW_SESSION_FOR_WORKSPACE_ACTION_ID } from './sessionsList.js'; import { ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { ISessionGroupsService } from '../../../../services/sessions/browser/sessionGroupsService.js'; -import { IsWorkspaceGroupCappedContext, SessionsViewFilterOptionsSubMenu, SessionsViewFilterSubMenu, SessionsViewGroupingContext, SessionsViewId, SessionsView, SessionsViewSortingContext, openSessionToTheSide } from './sessionsView.js'; +import { IsWorkspaceGroupCappedContext, SessionsViewFilterOptionsSubMenu, SessionsViewFilterSubMenu, SessionsViewGroupingContext, SessionsViewId, SessionsView, SessionsViewSortingContext } from './sessionsView.js'; import { Menus } from '../../../../browser/menus.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; @@ -85,7 +85,7 @@ function digitToKeyCode(digit: number): KeyCode { } } -const openSessionAtIndex = (accessor: ServicesAccessor, sessionIndex: unknown): void => { +const openSessionAtIndex = async (accessor: ServicesAccessor, sessionIndex: unknown): Promise => { if (typeof sessionIndex !== 'number') { return; } @@ -103,7 +103,9 @@ const openSessionAtIndex = (accessor: ServicesAccessor, sessionIndex: unknown): if (!target) { return; } - sessionsService.openSession(target.resource); + if (await sessionsService.canOpenSession(target)) { + await sessionsService.openChat(target, target.mainChat.get().resource); + } }; CommandsRegistry.registerCommand({ @@ -162,7 +164,9 @@ const navigateSessionInList = async (accessor: ServicesAccessor, direction: 'pre const target = visible[targetIndex]; if (target) { - await sessionsService.openSession(target.resource); + if (await sessionsService.canOpenSession(target)) { + await sessionsService.openChat(target, target.mainChat.get().resource); + } } }; @@ -1151,7 +1155,7 @@ registerAction2(class OpenSessionToTheSideAction extends Action2 { } const lastRequested = sessions[sessions.length - 1]; - await openSessionToTheSide(sessionsService, lastRequested); + await sessionsService.openSessionToSide(lastRequested); const visibleAfterOpen = sessionsService.visibleSessions.get(); const opened = visibleAfterOpen.find(s => s?.sessionId === lastRequested.sessionId); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index a0bb20b35dc..691fe5f823c 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { mainWindow } from '../../../../../base/browser/window.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { ExtUri } from '../../../../../base/common/resources.js'; import { constObservable, observableValue } from '../../../../../base/common/observable.js'; @@ -23,10 +24,13 @@ import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/ import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IAutomationRun } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; import { IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { getSessionChatDragData, isSessionChatDrag, SessionsDataTransfers } from '../../../../browser/dnd.js'; +import { IsPhoneLayoutContext } from '../../../../common/contextkeys.js'; import { ICustomViewService } from '../../../../services/customView/browser/customViewService.js'; import { ISessionsListModelService } from '../../../../services/sessions/browser/sessionsListModelService.js'; -import { IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; -import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { ChatInteractivity, ChatOriginKind, IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { computeReorderSortChanges, groupByDate, groupByWorkspace, groupSessionsForList, ISessionSection, limitSessionsForList, SessionSectionRenderer, SessionsFlatList, SessionsList, sortSessions, SessionsGrouping, SessionsSorting } from '../../browser/views/sessionsList.js'; import { getSessionSummaryHoverData } from '../../browser/sessionHoverContent.js'; @@ -811,6 +815,481 @@ suite('Sessions - SessionsList', () => { }); }); + suite('session chat rows', () => { + + function createChat(title: string, origin?: ChatOriginKind, interactivity = ChatInteractivity.Full, status = SessionStatus.Completed): IChat { + return upcastPartial({ + resource: URI.parse(`test-chat://${title.replaceAll(' ', '-')}`), + title: constObservable(title), + updatedAt: constObservable(new Date()), + status: constObservable(status), + interactivity: constObservable(interactivity), + origin: origin ? { kind: origin } : undefined, + }); + } + + function renderSessionChats(session: ISession, onChatOpen?: (session: ISession, chat: IChat, preserveFocus: boolean, sideBySide: boolean) => void, enableMotion = false): HTMLElement { + const harness = createListHarness(disposables, [session], enableMotion + ? instantiationService => instantiationService.stub(IAccessibilityService, new class extends TestAccessibilityService { + override isMotionReduced(): boolean { return false; } + }) + : {}); + const container = harness.createContainer(); + const list = harness.store.add(harness.instantiationService.createInstance(SessionsList, container, { + grouping: () => SessionsGrouping.Date, + sorting: () => SessionsSorting.Created, + onSessionOpen: () => { }, + onChatOpen, + })); + list.layout(300, 400); + return container; + } + + function chatRowTitles(container: HTMLElement): string[] { + return [...container.querySelectorAll('.session-chat-title')].map(element => element.textContent ?? ''); + } + + test('shows non-main and side chats and excludes the main chat, subagents, and hidden chats', () => { + const main = createChat('Main chat'); + const peer = createChat('Peer chat', ChatOriginKind.User); + const fork = createChat('Forked chat', ChatOriginKind.Fork); + const subagent = createChat('Subagent chat', ChatOriginKind.Tool); + const side = createChat('Side chat', ChatOriginKind.SideChat); + const hidden = createChat('Hidden chat', undefined, ChatInteractivity.Hidden); + const base = createTestSession('Session').session; + const session: ISession = { + ...base, + chats: constObservable([main, peer, fork, subagent, side, hidden]), + mainChat: constObservable(main), + capabilities: constObservable({ supportsMultipleChats: true }), + }; + + const container = renderSessionChats(session); + + assert.deepStrictEqual( + [...container.querySelectorAll('.session-chat-item')].map(item => ({ + title: item.querySelector('.session-chat-title')?.textContent, + last: item.classList.contains('last-chat'), + })), + [ + { title: 'Peer chat', last: false }, + { title: 'Forked chat', last: false }, + { title: 'Side chat', last: true }, + ] + ); + }); + + test('updates nested chat rows when the session chat catalog changes', () => { + const main = createChat('Main chat'); + const peer = createChat('Peer chat', ChatOriginKind.User); + const chats = observableValue('session-chats', [main]); + const base = createTestSession('Session').session; + const session: ISession = { + ...base, + chats, + mainChat: constObservable(main), + capabilities: constObservable({ supportsMultipleChats: true }), + }; + const container = renderSessionChats(session); + const before = chatRowTitles(container); + + chats.set([main, peer], undefined); + + assert.deepStrictEqual({ + before, + after: chatRowTitles(container), + }, { + before: [], + after: ['Peer chat'], + }); + }); + + test('hides the main chat even when its title matches the session title', () => { + const main = createChat('Session'); + const peer = createChat('Peer chat', ChatOriginKind.User); + const base = createTestSession('Session').session; + const session: ISession = { + ...base, + chats: constObservable([main, peer]), + mainChat: constObservable(main), + capabilities: constObservable({ supportsMultipleChats: true }), + }; + + const container = renderSessionChats(session); + + assert.deepStrictEqual({ + chats: chatRowTitles(container), + hasTwistie: container.querySelector('.session-chat-twistie')?.classList.contains('collapsible'), + }, { + chats: ['Peer chat'], + hasTwistie: true, + }); + }); + + test('shows progress for active chats and a dot for inactive chats', () => { + const main = createChat('Main chat'); + const active = createChat('Active chat', ChatOriginKind.User, ChatInteractivity.Full, SessionStatus.InProgress); + const base = createTestSession('Session').session; + const session: ISession = { + ...base, + chats: constObservable([main, active]), + mainChat: constObservable(main), + capabilities: constObservable({ supportsMultipleChats: true }), + }; + const container = renderSessionChats(session, undefined, true); + + assert.deepStrictEqual(Object.fromEntries( + [...container.querySelectorAll('.session-chat-item')].map(item => [ + item.querySelector('.session-chat-title')?.textContent, + { + hasProgress: !!item.querySelector('.session-chat-icon > .monaco-pixel-spinner'), + hasDot: !!item.querySelector('.session-chat-icon > .codicon-circle-small-filled'), + hasDiscussion: !!item.querySelector('.session-chat-icon > .codicon-comment-discussion'), + ariaLabel: item.closest('.monaco-list-row')?.getAttribute('aria-label'), + }, + ]) + ), { + 'Active chat': { hasProgress: true, hasDot: false, hasDiscussion: false, ariaLabel: 'Active chat, chat, updated now, State: In Progress' }, + }); + }); + + test('updates rendered chat row heights across phone layout changes', () => { + const main = createChat('Main chat'); + const peer = createChat('Peer chat', ChatOriginKind.User); + const base = createTestSession('Session').session; + const session: ISession = { ...base, chats: constObservable([main, peer]), mainChat: constObservable(main) }; + const harness = createListHarness(disposables, [session], instantiationService => { + instantiationService.stub(IContextKeyService, disposables.add(new ContextKeyService(new TestConfigurationService()))); + }); + const phoneLayout = IsPhoneLayoutContext.bindTo(harness.instantiationService.get(IContextKeyService)); + const container = harness.createContainer(); + const list = harness.store.add(harness.instantiationService.createInstance(SessionsList, container, { + grouping: () => SessionsGrouping.Date, + sorting: () => SessionsSorting.Created, + onSessionOpen: () => { }, + })); + list.layout(300, 400); + const chatRow = container.querySelector('.session-chat-item')?.closest('.monaco-list-row'); + assert.ok(chatRow); + const desktopHeight = chatRow.style.height; + + phoneLayout.set(true); + const phoneChatRow = container.querySelector('.session-chat-item')?.closest('.monaco-list-row'); + assert.ok(phoneChatRow); + + assert.deepStrictEqual({ desktopHeight, phoneHeight: phoneChatRow.style.height }, { + desktopHeight: '28px', + phoneHeight: '44px', + }); + }); + + test('opens the selected nested chat', () => { + const main = createChat('Main chat'); + const peer = createChat('Peer chat', ChatOriginKind.User); + const base = createTestSession('Session').session; + const session: ISession = { + ...base, + chats: constObservable([main, peer]), + mainChat: constObservable(main), + capabilities: constObservable({ supportsMultipleChats: true }), + }; + const opened: { session: ISession; chat: IChat; preserveFocus: boolean; sideBySide: boolean }[] = []; + const container = renderSessionChats(session, (openedSession, chat, preserveFocus, sideBySide) => { + opened.push({ session: openedSession, chat, preserveFocus, sideBySide }); + }); + const peerRow = [...container.querySelectorAll('.session-chat-item')] + .find(element => element.textContent === 'Peer chat'); + assert.ok(peerRow); + + peerRow.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 })); + + assert.deepStrictEqual(opened, [{ + session, + chat: peer, + preserveFocus: false, + sideBySide: false, + }]); + }); + + test('opens a nested chat to the side with the session row modifier gesture', () => { + const main = createChat('Main chat'); + const peer = createChat('Peer chat', ChatOriginKind.User); + const base = createTestSession('Session').session; + const session: ISession = { + ...base, + chats: constObservable([main, peer]), + mainChat: constObservable(main), + capabilities: constObservable({ supportsMultipleChats: true }), + }; + const opened: { chat: IChat; preserveFocus: boolean; sideBySide: boolean }[] = []; + const container = renderSessionChats(session, (_session, chat, preserveFocus, sideBySide) => { + opened.push({ chat, preserveFocus, sideBySide }); + }); + const peerRow = [...container.querySelectorAll('.session-chat-item')] + .find(element => element.textContent === 'Peer chat'); + assert.ok(peerRow); + + peerRow.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0, altKey: true })); + + assert.deepStrictEqual(opened, [{ + chat: peer, + preserveFocus: false, + sideBySide: true, + }]); + }); + + test('coalesces restored active chat selection without flashing the parent session', async () => { + const main = createChat('Main chat'); + const first = createChat('First chat', ChatOriginKind.User); + const second = createChat('Second chat', ChatOriginKind.User); + const side = createChat('Side chat', ChatOriginKind.SideChat); + const activeChat = observableValue('active-chat', first); + const base = createTestSession('Session').session; + const session: ISession = { + ...base, + chats: constObservable([main, first, second, side]), + mainChat: constObservable(main), + capabilities: constObservable({ supportsMultipleChats: true }), + }; + const activeSession = upcastPartial({ + ...session, + activeChat, + sticky: constObservable(false), + isCreated: constObservable(true), + visibleChatTabs: constObservable([main, first, second, side]), + }); + const harness = createListHarness(disposables, [session], instantiationService => { + instantiationService.stub(ISessionsService, new class extends mock() { + override readonly activeSession = constObservable(activeSession); + override readonly visibleSessions = constObservable([activeSession]); + }); + }); + + const container = harness.createContainer(); + const list = harness.store.add(harness.instantiationService.createInstance(SessionsList, container, { + grouping: () => SessionsGrouping.Date, + sorting: () => SessionsSorting.Created, + onSessionOpen: () => { }, + })); + list.layout(300, 400); + const initiallySelected = container.querySelector('.monaco-list-row.selected .session-chat-title')?.textContent; + const twistie = container.querySelector('.session-chat-twistie'); + assert.ok(twistie); + twistie.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 })); + const focusTarget = mainWindow.document.createElement('button'); + mainWindow.document.body.appendChild(focusTarget); + disposables.add({ dispose: () => focusTarget.remove() }); + focusTarget.focus(); + + activeChat.set(main, undefined); + const selectionDuringRestore = container.querySelector('.monaco-list-row.selected .session-chat-title')?.textContent; + const parentDuringRestore = container.querySelector('.monaco-list-row.selected .session-title')?.textContent; + activeChat.set(second, undefined); + const selectionBeforeFrame = container.querySelector('.monaco-list-row.selected .session-chat-title')?.textContent; + const parentBeforeFrame = container.querySelector('.monaco-list-row.selected .session-title')?.textContent; + await new Promise(resolve => mainWindow.requestAnimationFrame(() => resolve())); + const selectedChat = container.querySelector('.monaco-list-row.selected .session-chat-title')?.textContent; + activeChat.set(side, undefined); + await new Promise(resolve => mainWindow.requestAnimationFrame(() => resolve())); + const selectedSideChat = container.querySelector('.monaco-list-row.selected .session-chat-title')?.textContent; + activeChat.set(main, undefined); + await new Promise(resolve => mainWindow.requestAnimationFrame(() => resolve())); + + assert.deepStrictEqual({ + initiallySelected, + selectionDuringRestore, + parentDuringRestore, + selectionBeforeFrame, + parentBeforeFrame, + selectedChat, + selectedSideChat, + mainSelection: container.querySelector('.monaco-list-row.selected .session-title')?.textContent, + expanded: twistie.closest('.monaco-list-row')?.getAttribute('aria-expanded'), + activeElement: mainWindow.document.activeElement, + }, { + initiallySelected: 'First chat', + selectionDuringRestore: undefined, + parentDuringRestore: undefined, + selectionBeforeFrame: undefined, + parentBeforeFrame: undefined, + selectedChat: 'Second chat', + selectedSideChat: 'Side chat', + mainSelection: 'Session', + expanded: 'true', + activeElement: focusTarget, + }); + }); + + test('ordinary list updates preserve a collapsed active session and user selection', () => { + const main = createChat('Main chat'); + const peer = createChat('Peer chat', ChatOriginKind.User); + const activeSessionBase = createTestSession('Session').session; + const session: ISession = { ...activeSessionBase, chats: constObservable([main, peer]), mainChat: constObservable(main) }; + const activeSession = upcastPartial({ + ...session, + activeChat: constObservable(peer), + sticky: constObservable(false), + isCreated: constObservable(true), + visibleChatTabs: constObservable([main, peer]), + }); + const harness = createListHarness(disposables, [session], instantiationService => { + instantiationService.stub(ISessionsService, new class extends mock() { + override readonly activeSession = constObservable(activeSession); + override readonly visibleSessions = constObservable([activeSession]); + }); + }); + const container = harness.createContainer(); + const list = harness.store.add(harness.instantiationService.createInstance(SessionsList, container, { + grouping: () => SessionsGrouping.Date, + sorting: () => SessionsSorting.Created, + onSessionOpen: () => { }, + })); + list.layout(300, 400); + list.reveal(session.resource); + const twistie = container.querySelector('.session-chat-twistie'); + assert.ok(twistie); + twistie.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 })); + + list.update(); + + assert.deepStrictEqual({ + expanded: twistie.closest('.monaco-list-row')?.getAttribute('aria-expanded'), + selected: container.querySelector('.monaco-list-row.selected .session-title')?.textContent, + }, { + expanded: 'false', + selected: 'Session', + }); + }); + + test('drags a nested chat with the chat-group payload instead of a session payload', () => { + const main = createChat('Main chat'); + const peer = createChat('Peer chat', ChatOriginKind.User); + const base = createTestSession('Session').session; + const session: ISession = { + ...base, + chats: constObservable([main, peer]), + mainChat: constObservable(main), + capabilities: constObservable({ supportsMultipleChats: true }), + }; + const container = renderSessionChats(session); + const peerRow = [...container.querySelectorAll('.session-chat-item')] + .find(element => element.textContent === 'Peer chat') + ?.closest('.monaco-list-row'); + assert.ok(peerRow); + const dataTransfer = new DataTransfer(); + const dragStart = new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer }); + + peerRow.dispatchEvent(dragStart); + + assert.deepStrictEqual({ + isChatDrag: isSessionChatDrag(dragStart), + isSameSessionDrag: isSessionChatDrag(dragStart, session.sessionId), + sessionPayload: dataTransfer.getData(SessionsDataTransfers.SESSION), + chatPayload: getSessionChatDragData(dragStart), + }, { + isChatDrag: true, + isSameSessionDrag: true, + sessionPayload: '', + chatPayload: { sessionId: session.sessionId, resource: peer.resource.toString() }, + }); + }); + + test('uses the native twistie only for sessions with nested chats', () => { + const main = createChat('Main chat'); + const peer = createChat('Peer chat', ChatOriginKind.User); + const multiChatBase = createTestSession('Multi-chat session').session; + const multiChatSession: ISession = { + ...multiChatBase, + chats: constObservable([main, peer]), + mainChat: constObservable(main), + capabilities: constObservable({ supportsMultipleChats: true }), + }; + const singleChatBase = createTestSession('Single-chat session').session; + const singleChatSession: ISession = { + ...singleChatBase, + chats: constObservable([main]), + mainChat: constObservable(main), + capabilities: constObservable({ supportsMultipleChats: true }), + }; + const harness = createListHarness(disposables, [multiChatSession, singleChatSession]); + const container = harness.createContainer(); + const list = harness.store.add(harness.instantiationService.createInstance(SessionsList, container, { + grouping: () => SessionsGrouping.Date, + sorting: () => SessionsSorting.Created, + onSessionOpen: () => { }, + })); + list.layout(300, 400); + const rows = Object.fromEntries([...container.querySelectorAll('.session-item')].map(item => { + const row = item.closest('.monaco-list-row'); + const twistie = row?.querySelector('.monaco-tl-twistie'); + row?.classList.add('focused'); + const twistieStyle = twistie?.classList.contains('session-chat-twistie') + ? mainWindow.getComputedStyle(twistie) + : undefined; + return [item.querySelector('.session-title')?.textContent, { + expanded: row?.getAttribute('aria-expanded'), + hasSessionChatTwistie: twistie?.classList.contains('session-chat-twistie'), + hasHiddenTwistie: twistie?.classList.contains('force-no-twistie'), + hasNativeGlyph: twistie?.classList.contains('codicon-tree-item-expanded'), + isCollapsible: twistie?.classList.contains('collapsible'), + isCollapsed: twistie?.classList.contains('collapsed'), + fontSize: twistieStyle?.fontSize, + opacity: twistieStyle?.opacity, + paddingLeft: twistie?.style.paddingLeft, + pointerEvents: twistieStyle?.pointerEvents, + }]; + })); + + assert.deepStrictEqual(rows, { + 'Multi-chat session': { + expanded: 'true', + hasSessionChatTwistie: true, + hasHiddenTwistie: false, + hasNativeGlyph: true, + isCollapsible: true, + isCollapsed: false, + fontSize: '16px', + opacity: '1', + paddingLeft: '0px', + pointerEvents: 'auto', + }, + 'Single-chat session': { + expanded: null, + hasSessionChatTwistie: false, + hasHiddenTwistie: true, + hasNativeGlyph: false, + isCollapsible: false, + isCollapsed: false, + fontSize: undefined, + opacity: undefined, + paddingLeft: '0px', + pointerEvents: undefined, + }, + }); + + const multiChatItem = [...container.querySelectorAll('.session-item')] + .find(item => item.querySelector('.session-title')?.textContent === 'Multi-chat session'); + assert.ok(multiChatItem); + multiChatItem.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0, detail: 1 })); + multiChatItem.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0, detail: 2 })); + assert.strictEqual(multiChatItem.closest('.monaco-list-row')?.getAttribute('aria-expanded'), 'true'); + + const twistie = multiChatItem.closest('.monaco-list-row')?.querySelector('.monaco-tl-twistie'); + assert.ok(twistie); + twistie.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 })); + + assert.deepStrictEqual({ + expanded: twistie.closest('.monaco-list-row')?.getAttribute('aria-expanded'), + isCollapsed: twistie.classList.contains('collapsed'), + visibleChats: chatRowTitles(container), + }, { + expanded: 'false', + isCollapsed: true, + visibleChats: [], + }); + }); + }); + suite('SessionsFlatList quick-chat presentation', () => { function renderQuickChat(useCompactQuickChatRows: boolean) { diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsListContextMenu.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsListContextMenu.test.ts index ba45b188099..6fcbe5ed35d 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsListContextMenu.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsListContextMenu.test.ts @@ -8,15 +8,24 @@ import { IContextMenuDelegate } from '../../../../../base/browser/contextmenu.js import { IAction, SubmenuAction } from '../../../../../base/common/actions.js'; import { Event } from '../../../../../base/common/event.js'; import { isDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { mock } from '../../../../../base/test/common/mock.js'; +import { constObservable } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { IMenu, IMenuService, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; -import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IMenu, IMenuService, isIMenuItem, MenuItemAction, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; +import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; +import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; import { ISessionGroup, ISessionGroupsService } from '../../../../services/sessions/browser/sessionGroupsService.js'; +import { ISessionsPartService } from '../../../../services/sessions/browser/sessionsPartService.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { ChatInteractivity, IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; +import type { SessionView } from '../../../../browser/parts/sessionView.js'; +import { Menus } from '../../../../browser/menus.js'; import { SessionsGrouping, SessionsList, SessionsSorting } from '../../browser/views/sessionsList.js'; import { createListHarness, createSession } from './sessionsListTestUtils.js'; +import '../../browser/sessionsActions.js'; class TestContextMenuService extends mock() { override readonly onDidShowContextMenu = Event.None; @@ -160,4 +169,83 @@ suite('Sessions list context menus', () => { disposableIds: [], }); }); + + test('chat rows expose capability-gated rename, side-open, and deletion', async () => { + assert.strictEqual(MenuRegistry.getMenuItems(Menus.SessionChatItemContext).length, 3); + const createChat = (title: string, canRename: boolean, canDelete: boolean): IChat => upcastPartial({ + resource: URI.parse(`test-chat:/${title}`), + title: constObservable(title), + updatedAt: constObservable(new Date()), + status: constObservable(SessionStatus.Completed), + interactivity: constObservable(ChatInteractivity.Full), + capabilities: constObservable({ canRename, canDelete }), + }); + const main = createChat('Session', true, true); + const peer = createChat('Peer', true, true); + const nonDeletable = createChat('Read Only', false, false); + const { session: baseSession } = createSession('Session'); + const session: ISession = { + ...baseSession, + chats: constObservable([main, peer, nonDeletable]), + mainChat: constObservable(main), + }; + const renameInputs: string[] = []; + const openedToSide: IChat[] = []; + const harness = createListHarness(disposables, [session], instantiationService => { + instantiationService.stub(IQuickInputService, new class extends mock() { + override async input(options?: { value?: string }): Promise { + renameInputs.push(options?.value ?? ''); + return ' Renamed Peer '; + } + }); + instantiationService.stub(ISessionsService, new class extends mock() { + override readonly activeSession = constObservable(undefined); + override readonly visibleSessions = constObservable([]); + override async canOpenSession(): Promise { return true; } + override showSession(): void { } + }); + instantiationService.stub(ISessionsPartService, new class extends mock() { + override getSessionView(): SessionView { + return upcastPartial({ + openChatToSide: async (resource: URI) => { + const chat = session.chats.get().find(candidate => candidate.resource.toString() === resource.toString()); + if (chat) { + openedToSide.push(chat); + } + }, + }); + } + }); + }); + const menuItems = MenuRegistry.getMenuItems(Menus.SessionChatItemContext).filter(isIMenuItem); + assert.deepStrictEqual(menuItems.map(item => ({ + id: item.command.id, + group: item.group, + order: item.order, + when: item.when?.serialize(), + })), [ + { id: 'sessions.list.renameChat', group: '1_chat', order: 1, when: 'sessionChatItem.canRename && !sessionChatItem.isUntitled' }, + { id: 'sessions.list.openChatToSide', group: '1_chat', order: 2, when: undefined }, + { id: 'sessions.list.deleteChat', group: '2_delete', order: 1, when: 'sessionChatItem.canDelete' }, + ]); + const chatContext = { session, chat: peer }; + for (const actionId of ['sessions.list.renameChat', 'sessions.list.openChatToSide', 'sessions.list.deleteChat']) { + await harness.instantiationService.invokeFunction(CommandsRegistry.getCommand(actionId)!.handler, chatContext); + } + const readOnlyContext = { session, chat: nonDeletable }; + await harness.instantiationService.invokeFunction(CommandsRegistry.getCommand('sessions.list.renameChat')!.handler, readOnlyContext); + await harness.instantiationService.invokeFunction(CommandsRegistry.getCommand('sessions.list.deleteChat')!.handler, readOnlyContext); + + assert.deepStrictEqual({ + renameInputs, + renamedChats: harness.managementService.renamedChats, + openedToSide, + deletedChats: harness.managementService.deletedChats, + }, { + renameInputs: ['Peer'], + renamedChats: [{ session, chatResource: peer.resource, title: 'Renamed Peer' }], + openedToSide: [peer], + deletedChats: [{ session, chatResource: peer.resource }], + }); + }); }); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts index 056056db122..b0fc02fb66d 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts @@ -42,6 +42,8 @@ export class TestSessionsManagementService extends mock { + this.deletedChats.push({ session, chatResource }); + } + + override async renameChat(session: ISession, chatResource: URI, title: string): Promise { + this.renamedChats.push({ session, chatResource, title }); + } } export interface ITestSession { diff --git a/src/vs/sessions/services/sessions/browser/sessionsService.ts b/src/vs/sessions/services/sessions/browser/sessionsService.ts index 4fcc34ee958..564f9e92695 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsService.ts @@ -176,6 +176,9 @@ export interface ISessionsService { */ openSession(sessionResource: URI, options?: { preserveFocus?: boolean }): Promise; + /** Place a session to the right of the last visible session and activate it. */ + openSessionToSide(session: ISession, options?: { preserveFocus?: boolean; chatResource?: URI }): Promise; + /** * Whether the given session may be opened, honoring workspace trust. Prompts * for trust on any untrusted folder the session runs in and resolves to @@ -185,8 +188,10 @@ export interface ISessionsService { /** * Open a specific chat within a session and show it in the grid. + * When `options.preserveFocus` is set, the chat is shown without moving + * keyboard focus into it. */ - openChat(session: ISession, chatUri: URI): Promise; + openChat(session: ISession, chatUri: URI, options?: { preserveFocus?: boolean }): Promise; /** * Close a chat from the session view. The chat is hidden from the tab strip @@ -718,12 +723,12 @@ export class SessionsService extends Disposable implements ISessionsService { return this._visibility.setActive(session, preserveFocus); } - async openChat(session: ISession, chatUri: URI): Promise { + async openChat(session: ISession, chatUri: URI, options?: { preserveFocus?: boolean }): Promise { const t0 = Date.now(); this._cancelRestore(); const token = this._startOpenSession(); this.logService.trace(`[SessionsView] openChat start uri=${chatUri.toString()} provider=${session.providerId}`); - this._activate(session); + this._activate(session, options?.preserveFocus); if (!await this._waitForSessionToLoad(session, token)) { this.logService.trace(`[SessionsView] openChat cancelled while waiting for session to load uri=${chatUri.toString()}`); return; @@ -813,6 +818,19 @@ export class SessionsService extends Disposable implements ISessionsService { await this._waitForOpenSessionToLoad(sessionData, token); } + async openSessionToSide(session: ISession, options?: { preserveFocus?: boolean; chatResource?: URI }): Promise { + const visible = this.visibleSessions.get(); + const lastVisible = visible[visible.length - 1]; + if (lastVisible && lastVisible.sessionId !== session.sessionId) { + this.insertAt(session, lastVisible.sessionId, 'right'); + } + if (options?.chatResource) { + await this.openChat(session, options.chatResource, { preserveFocus: options.preserveFocus }); + } else { + await this.openSession(session.resource, { preserveFocus: options?.preserveFocus }); + } + } + async canOpenSession(session: ISession): Promise { // Re-focusing the already-active session is not a new open, so never gate it. if (this.activeSession.get()?.sessionId === session.sessionId) { diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 1a4879edcd4..3916dead328 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -2611,6 +2611,32 @@ suite('SessionsManagementService', () => { }); }); + test('opens a session and targeted chat to the side', async () => { + const firstChat = { ...stubChat, resource: URI.parse('test:///first/main') }; + const targetMain = { ...stubChat, resource: URI.parse('test:///target/main') }; + const targetPeer = { ...stubChat, resource: URI.parse('test:///target/peer') }; + const first = stubSession({ sessionId: 'first', providerId: 'test', chats: constObservable([firstChat]), mainChat: constObservable(firstChat) }); + const target = stubSession({ sessionId: 'target', providerId: 'test', chats: constObservable([targetMain, targetPeer]), mainChat: constObservable(targetMain) }); + const provider = new class extends TestSessionsProvider { + constructor() { super(first); } + override getSessions(): ISession[] { return [first, target]; } + }; + const { view } = createSessionsManagementService(first, disposables, provider); + await view.openSession(first.resource); + + await view.openSessionToSide(target, { chatResource: targetPeer.resource }); + + assert.deepStrictEqual({ + visible: view.visibleSessions.get().map(session => session?.sessionId), + activeSession: view.activeSession.get()?.sessionId, + activeChat: view.activeSession.get()?.activeChat.get().resource.toString(), + }, { + visible: ['first', 'target'], + activeSession: 'target', + activeChat: targetPeer.resource.toString(), + }); + }); + test('replacing a session only swaps the active session when it matches `from`', async () => { const a = stubSession({ sessionId: 'a', providerId: 'test' }); const b = stubSession({ sessionId: 'b', providerId: 'test' }); diff --git a/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts b/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts index 3c0b8e4500e..8a107708a76 100644 --- a/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts +++ b/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts @@ -223,7 +223,7 @@ suite('setSessionContextKeys - side chat', () => { shouldShowChatTabs: constObservable(true), }); setActiveSessionContextKeys(withSideChat, contextKeyService, undefined); - assert.strictEqual(SessionHasMultipleCommittedChatsContext.getValue(contextKeyService), true); + const withSideChatCommittedChats = SessionHasMultipleCommittedChatsContext.getValue(contextKeyService); const withToolChat = upcastPartial({ ...stubSession({ sessionId: 'tool', chats: constObservable([mainChat, toolChat]), mainChat: constObservable(mainChat) }), @@ -234,7 +234,13 @@ suite('setSessionContextKeys - side chat', () => { shouldShowChatTabs: constObservable(false), }); setActiveSessionContextKeys(withToolChat, contextKeyService, undefined); - assert.strictEqual(SessionHasMultipleCommittedChatsContext.getValue(contextKeyService), false); + assert.deepStrictEqual({ + withSideChatCommittedChats, + withToolChatCommittedChats: SessionHasMultipleCommittedChatsContext.getValue(contextKeyService), + }, { + withSideChatCommittedChats: true, + withToolChatCommittedChats: false, + }); }); test('shows subagents only for the active chat scope', () => { diff --git a/src/vs/sessions/test/browser/chatCompositeBar.test.ts b/src/vs/sessions/test/browser/chatCompositeBar.test.ts index 692b45236e7..d5d666072e1 100644 --- a/src/vs/sessions/test/browser/chatCompositeBar.test.ts +++ b/src/vs/sessions/test/browser/chatCompositeBar.test.ts @@ -112,7 +112,6 @@ function createHarness(disposables: Pick, options?: { re visible: session.shouldShowChatTabs, showSessionActions: session.shouldShowChatTabs, openChat: resource => { sessionsService.openChat(session, resource); }, - newChat: () => { }, }; bar.setGroup(delegate); const container = mainWindow.document.createElement('div'); @@ -146,10 +145,10 @@ suite('Sessions - ChatCompositeBar', () => { }); }); - test('hides New Chat for workspace-less sessions', () => { - const { bar } = createHarness(disposables, { isQuickChat: true }); + test('does not render New Chat in the tab bar', () => { + const { bar } = createHarness(disposables); - assert.strictEqual(bar.element.querySelector('.chat-composite-bar-new-chat')?.classList.contains('hidden'), true); + assert.strictEqual(bar.element.querySelector('.chat-composite-bar-new-chat'), null); }); test('middle-click closes the targeted inactive non-main chat', () => { diff --git a/src/vs/sessions/test/browser/chatGroupsView.test.ts b/src/vs/sessions/test/browser/chatGroupsView.test.ts index 2785c881c9f..08267b9d7e9 100644 --- a/src/vs/sessions/test/browser/chatGroupsView.test.ts +++ b/src/vs/sessions/test/browser/chatGroupsView.test.ts @@ -116,9 +116,14 @@ class TestActiveSession extends mock() { class TestSessionsService extends mock() { override readonly activeSession = observableValue(this, undefined); - newChatGate: Promise | undefined; + openChatGate: Promise | undefined; + openChatError: Error | undefined; override async openChat(session: ISession, chatUri: URI): Promise { + await this.openChatGate; + if (this.openChatError) { + throw this.openChatError; + } if (!(session instanceof TestActiveSession)) { return; } @@ -133,16 +138,6 @@ class TestSessionsService extends mock() { this.activeSession.set(session, undefined); } - override async openNewChatInSession(session: ISession): Promise { - if (!(session instanceof TestActiveSession)) { - return; - } - await this.newChatGate; - const chat = createChat(`new-${session.allChats.get().length}`, SessionStatus.Untitled); - session.allChats.set([...session.allChats.get(), chat], undefined); - session.visibleChatTabs.set([...session.visibleChatTabs.get(), chat], undefined); - session.activeChat.set(chat, undefined); - } } interface IChatGroupsHarness { @@ -295,6 +290,48 @@ suite('Sessions - ChatGroupsView', () => { }); }); + test('opening an existing main-group tab to the side moves it into its own group', async () => { + const { sessionsService, view } = createHarness(disposables); + const main = createChat('main'); + const secondary = createChat('secondary'); + const session = new TestActiveSession([main, secondary]); + view.setSession(session, options); + + const gate = new DeferredPromise(); + sessionsService.openChatGate = gate.p; + let settled = false; + const openPromise = view.openChatInNewGroup(secondary.resource).finally(() => settled = true); + await Promise.resolve(); + const settledBeforeOpen = settled; + gate.complete(); + await openPromise; + const afterSplit = Array.from(view.element.querySelectorAll('.chat-group-view')) + .map(group => Array.from(group.querySelectorAll('.chat-composite-bar-tab')).map(tab => tab.dataset.chatResource)); + await view.openChatInNewGroup(secondary.resource); + + assert.deepStrictEqual({ + settledBeforeOpen, + afterSplit, + groupCountAfterRepeatedOpen: view.groupCount.get(), + activeChat: session.activeChat.get().resource.toString(), + }, { + settledBeforeOpen: false, + afterSplit: [[main.resource.toString()], [secondary.resource.toString()]], + groupCountAfterRepeatedOpen: 2, + activeChat: secondary.resource.toString(), + }); + }); + + test('opening an existing tab to the side propagates open failures', async () => { + const { sessionsService, view } = createHarness(disposables); + const main = createChat('main'); + const secondary = createChat('secondary'); + view.setSession(new TestActiveSession([main, secondary]), options); + sessionsService.openChatError = new Error('open failed'); + + await assert.rejects(view.openChatInNewGroup(secondary.resource), /open failed/); + }); + test('dropping a hidden subagent on an edge opens it in a new group', async () => { const { view } = createHarness(disposables); const main = createChat('main'); @@ -403,38 +440,6 @@ suite('Sessions - ChatGroupsView', () => { }); }); - test('new chat remains assigned to the group where creation started', async () => { - const { sessionsService, view } = createHarness(disposables); - const main = createChat('main'); - const secondary = createChat('secondary'); - const session = new TestActiveSession([main, secondary]); - view.setSession(session, options); - view.splitChatToSide(secondary.resource); - view.focusAdjacentGroup('previous'); - const groups = Array.from(view.element.querySelectorAll('.chat-group-view')); - const mainGroup = groups.find(group => group.querySelector('.chat-composite-bar-tab')?.dataset.chatResource === main.resource.toString())!; - const gate = new DeferredPromise(); - sessionsService.newChatGate = gate.p; - - mainGroup.querySelector('.chat-composite-bar-new-chat .action-label')!.click(); - view.focusAdjacentGroup('next'); - gate.complete(); - await gate.p; - await Promise.resolve(); - await Promise.resolve(); - - const newChat = session.activeChat.get(); - assert.deepStrictEqual({ - mainGroupTabs: Array.from(mainGroup.querySelectorAll('.chat-composite-bar-tab')).map(tab => tab.dataset.chatResource), - secondaryGroupTabs: Array.from(groups.find(group => group !== mainGroup)!.querySelectorAll('.chat-composite-bar-tab')).map(tab => tab.dataset.chatResource), - focusInMainGroup: mainGroup.contains(mainWindow.document.activeElement), - }, { - mainGroupTabs: [main.resource.toString(), newChat.resource.toString()], - secondaryGroupTabs: [secondary.resource.toString()], - focusInMainGroup: true, - }); - }); - test('shows session actions in a single tab row and hides them for split groups', () => { const { view } = createHarness(disposables); const main = createChat('main'); diff --git a/src/vs/sessions/test/browser/sessionConversationGroups.test.ts b/src/vs/sessions/test/browser/sessionConversationGroups.test.ts index a194d0f41a7..692598fdc36 100644 --- a/src/vs/sessions/test/browser/sessionConversationGroups.test.ts +++ b/src/vs/sessions/test/browser/sessionConversationGroups.test.ts @@ -21,7 +21,7 @@ function createChat(id: string, origin?: IChatOrigin): IChat { suite('Sessions - Session conversation groups', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('keeps side chats top-level and separates subagents', () => { + test('omits side chats and separates active-chat subagents', () => { const activeChat = createChat('active'); assert.deepStrictEqual([ getSessionConversationGroupId(createChat('regular'), activeChat, extUri), @@ -30,7 +30,7 @@ suite('Sessions - Session conversation groups', () => { getSessionConversationGroupId(createChat('other-subagent', { kind: ChatOriginKind.Tool, parentChat: URI.parse('test-chat:/other') }), activeChat, extUri), ], [ SESSION_CONVERSATION_CHATS_GROUP, - SESSION_CONVERSATION_CHATS_GROUP, + undefined, SESSION_CONVERSATION_SUBAGENTS_GROUP, undefined, ]); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts index 5f11c34aaa2..a9afc2ba087 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts @@ -69,7 +69,6 @@ function createMockDelegate(session: IActiveSession, chats: readonly IChat[], ac visible: session.shouldShowChatTabs, showSessionActions: session.shouldShowChatTabs, openChat: () => { }, - newChat: () => { }, }; } diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts index 0111ce26c47..1f340265b89 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts @@ -74,6 +74,9 @@ function createWorkspace(label: string): ISessionWorkspace { function createSession(spec: ISessionSpec): ISession { const updatedAt = new Date(Date.now() - spec.minutesAgo * 60 * 1000); const description: IMarkdownString | undefined = spec.description ? new MarkdownString(spec.description) : undefined; + const mainChat = new class extends mock() { + override readonly resource = URI.parse(`vscode-session://session/${spec.id}/chat/main`); + }(); return new class extends mock() { override readonly sessionId = spec.id; override readonly resource = URI.parse(`vscode-session://session/${spec.id}`); @@ -92,6 +95,7 @@ function createSession(spec: ISessionSpec): ISession { override readonly changesSummary: IObservable = constObservable(spec.changesSummary); override readonly description: IObservable = constObservable(description); override readonly chats: IObservable = constObservable([]); + override readonly mainChat: IObservable = constObservable(mainChat); override readonly capabilities = constObservable({ supportsMultipleChats: false }); }(); } From 64ae77d65333f488e904836026aa59968069d188 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:20:03 +0200 Subject: [PATCH 055/116] Improve agent feedback session lookup performance (#332720) * perf: keep session lookups out of per-file feedback loops The agent feedback overlay walked every original/modified URI of the active multi-diff and did session-scoped work per file. Each candidate ended up in `ISessionsManagementService.getSession()`, which rebuilds every provider's session catalog and scans it linearly, so a Changes editor with thousands of files blocked the renderer for seconds (worst case: no feedback at all, since nothing stops the scan early). - Resolve sessions in `AgentFeedbackService` through the active session facade when it is the one asked for, plus a single-entry memo of the last lookup (hit or miss) that is dropped on any session catalog change. - Deduplicate candidates by session resource via `getFeedbackSessionCandidates` so feedback/backend work runs once per distinct session, lazily, preserving the existing early exit. Refs #332670 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Improve agent feedback session lookup performance --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentFeedbackService.ts | 6 +++ .../test/browser/agentFeedbackService.test.ts | 39 ++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts index 26c9c39a436..69f34871f33 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts @@ -19,6 +19,7 @@ import { IChatEditingService } from '../../../../workbench/contrib/chat/common/e import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { editingEntriesContainResource } from '../../../../workbench/contrib/chat/browser/sessionResourceMatching.js'; import { changeMatchesResource, getActiveResourceCandidates, IAgentFeedbackContext } from './agentFeedbackEditorUtils.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; @@ -347,6 +348,7 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe @IChatEditingService private readonly _chatEditingService: IChatEditingService, @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @ISessionsService private readonly _sessionsService: ISessionsService, + @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, @IEditorService private readonly _editorService: IEditorService, @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, @ILogService private readonly _logService: ILogService, @@ -399,7 +401,11 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe })); this._register(this._sessionsManagementService.onDidDeleteSession(session => this._forgetSession(session.resource))); + // Both the sessions of a provider and the set of providers itself decide + // what `getSession` resolves to, and a provider registration does not + // surface as a session change. this._register(this._sessionsManagementService.onDidChangeSessions(() => this._lastResolvedSession = undefined)); + this._register(this._sessionsProvidersService.onDidChangeProviders(() => this._lastResolvedSession = undefined)); } /** diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts index 9c8eed53f84..23fd146562b 100644 --- a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts @@ -25,7 +25,7 @@ import { IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { whenChatWidgetForSession } from '../../../chat/browser/chatWidgetUtils.js'; import { ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; -import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; +import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; @@ -63,6 +63,9 @@ suite('AgentFeedbackService - Ordering', () => { override onDidChangeSessions = Event.None; override getSession(_resource: URI) { return undefined; } }); + instantiationService.stub(ISessionsProvidersService, new class extends mock() { + override onDidChangeProviders = Event.None; + }); instantiationService.stub(ISessionsService, { activeSession: observableValue('activeSession', undefined) } as unknown as ISessionsService); service = store.add(instantiationService.createInstance(AgentFeedbackService)); @@ -370,6 +373,7 @@ suite('AgentFeedbackService - getSessionForFile', () => { let activeSessionObs: ISettableObservable; let sessions: Map; let sessionsChangedEmitter: Emitter; + let providersChangedEmitter: Emitter; let sessionS1: URI; let sessionS2: URI; @@ -419,6 +423,7 @@ suite('AgentFeedbackService - getSessionForFile', () => { activeSessionObs = observableValue('activeSession', undefined); sessions = new Map(); sessionsChangedEmitter = store.add(new Emitter()); + providersChangedEmitter = store.add(new Emitter()); managementLookups = 0; const instantiationService = store.add(new TestInstantiationService()); @@ -437,6 +442,9 @@ suite('AgentFeedbackService - getSessionForFile', () => { return sessions.get(resource.toString()); } }); + instantiationService.stub(ISessionsProvidersService, new class extends mock() { + override onDidChangeProviders = providersChangedEmitter.event; + }); instantiationService.stub(ISessionsService, { activeSession: activeSessionObs } as unknown as ISessionsService); service = store.add(instantiationService.createInstance(AgentFeedbackService)); @@ -682,6 +690,33 @@ suite('AgentFeedbackService - getSessionForFile', () => { }); }); + test('looks a non-active session up again when providers are added or removed', () => { + const provider = {} as ISessionsProvider; + setActiveSession(sessions.get(sessionS1.toString())!); + setVisibleEditors([pane(fileA)]); + setActiveSession(sessions.get(sessionS2.toString())!); + + // A registered provider is what makes a session resolvable, so a hit must + // not outlive its removal... + service.getSessionForFile(fileA); + sessions.delete(sessionS1.toString()); + providersChangedEmitter.fire({ added: [], removed: [provider] }); + const afterProviderRemoved = service.getSessionForFile(fileA); + + // ...and a miss must not outlive a provider that starts reporting it. + sessions.set(sessionS1.toString(), makeSession(sessionS1)); + providersChangedEmitter.fire({ added: [provider], removed: [] }); + const afterProviderAdded = service.getSessionForFile(fileA); + + assert.deepStrictEqual({ + afterProviderRemoved: afterProviderRemoved?.resource.toString(), + afterProviderAdded: afterProviderAdded?.resource.toString(), + }, { + afterProviderRemoved: undefined, + afterProviderAdded: sessionS1.toString(), + }); + }); + test('returns undefined when the active session has Untitled status', () => { sessions.set(sessionS1.toString(), makeSession(sessionS1, SessionStatus.Untitled)); setActiveSession(sessions.get(sessionS1.toString())!); @@ -740,6 +775,7 @@ suite('AgentFeedbackService - State', () => { override visibleEditorPanes = []; }); instantiationService.stub(ISessionsProvidersService, new class extends mock() { + override onDidChangeProviders = Event.None; override getProvider(_providerId: string): T | undefined { return undefined; } }); instantiationService.stub(ISessionsManagementService, new class extends mock() { @@ -845,6 +881,7 @@ suite('AgentFeedbackService - Submit (agent host)', () => { override visibleEditorPanes = []; }); instantiationService.stub(ISessionsProvidersService, new class extends mock() { + override onDidChangeProviders = Event.None; override getProvider(_providerId: string): T | undefined { return undefined; } }); instantiationService.stub(ISessionsManagementService, new class extends mock() { From 12dcb6bdba0ada99a44db9df918ee6bfd3b33cb3 Mon Sep 17 00:00:00 2001 From: Aaron Munger <2019016+amunger@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:48:30 -0700 Subject: [PATCH 056/116] agentHost: add assignment context to telemetry (#332602) * agentHost: add runtime assignment context to telemetry Promote assignment contexts observed on forwarded Copilot runtime telemetry to Agent Host-wide experiment properties so subsequent events carry ExP attribution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: preserve VS Code assignment telemetry Keep the workbench TAS context on forwarded Copilot SDK events under a non-colliding property when the runtime assignment context owns abexp.assignmentcontext. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: use workbench assignment context Make the forwarded workbench TAS value the sole source of abexp.assignmentcontext for Agent Host telemetry, while runtime notifications provide only secondary_assignment_context. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/copilot/copilotAgent.ts | 14 ++-- .../copilotGitHubTelemetryForwarder.ts | 9 -- .../copilotSecondaryAssignmentContext.ts | 31 +++++++ .../agentHost/test/node/copilotAgent.test.ts | 82 +++++++++---------- .../copilotGitHubTelemetryForwarder.test.ts | 39 +-------- .../copilotSecondaryAssignmentContext.test.ts | 58 +++++++++++++ .../telemetry/common/assignmentContext.ts | 19 +++++ .../api/browser/mainThreadTelemetry.ts | 12 +-- 8 files changed, 160 insertions(+), 104 deletions(-) create mode 100644 src/vs/platform/agentHost/node/copilot/copilotSecondaryAssignmentContext.ts create mode 100644 src/vs/platform/agentHost/test/node/copilotSecondaryAssignmentContext.test.ts create mode 100644 src/vs/platform/telemetry/common/assignmentContext.ts diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 82df23ceaf5..1a03ff153a1 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -79,6 +79,7 @@ import { createCopilotCliEnvironment } from './copilotCliEnvironment.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js'; import { CopilotGitHubTelemetryForwarder } from './copilotGitHubTelemetryForwarder.js'; +import { CopilotSecondaryAssignmentContext } from './copilotSecondaryAssignmentContext.js'; import { CopilotSessionLauncher, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, isCopilotReasoningEffort, resolveCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; import { CopilotAgentStartupConfig } from './copilotAgentStartupConfig.js'; import { ShellManager } from './copilotShellTools.js'; @@ -875,7 +876,7 @@ export class CopilotAgent extends Disposable implements IAgent { private readonly _plugins: PluginController; private readonly _sessionLauncher: CopilotSessionLauncher; private readonly _gitHubTelemetryForwarder: CopilotGitHubTelemetryForwarder; - private _vscodeAssignmentContext: string | undefined; + private readonly _secondaryAssignmentContext: CopilotSecondaryAssignmentContext; private readonly _githubTelemetryRouter: AgentHostGitHubTelemetryRouter | undefined; readonly onDidCustomizationsChange: Event; /** Per-session active client state for tools + plugin snapshot tracking. */ @@ -916,7 +917,8 @@ export class CopilotAgent extends Disposable implements IAgent { this._plugins = this._register(this._instantiationService.createInstance(PluginController, () => this._ensureClient())); this._sessionLauncher = this._instantiationService.createInstance(CopilotSessionLauncher); this._configurationService.publishRootTransientValues?.({ [CopilotCliVSCodeAssignmentContextKey]: undefined }); - this._gitHubTelemetryForwarder = this._instantiationService.createInstance(CopilotGitHubTelemetryForwarder, () => this._restrictedTelemetryEnabled, () => this._vscodeAssignmentContext); + this._gitHubTelemetryForwarder = this._instantiationService.createInstance(CopilotGitHubTelemetryForwarder, () => this._restrictedTelemetryEnabled); + this._secondaryAssignmentContext = this._instantiationService.createInstance(CopilotSecondaryAssignmentContext); this._register(this._configurationService.onDidRootConfigChange(() => this._updateVSCodeAssignmentContext())); this._updateVSCodeAssignmentContext(); this._slashCommandProvider = new CopilotSlashCommandProvider(() => this._ensureClient().then(c => c.rpc.commands.list().then(c => c.commands)), this._logService); @@ -1070,15 +1072,10 @@ export class CopilotAgent extends Disposable implements IAgent { ); } - /** - * A key absent from root config (e.g. dropped by a schema-filtered replace) - * keeps the last-known context sticky; an explicit empty-string dispatch - * from the workbench clears it. - */ private _updateVSCodeAssignmentContext(): void { const value = this._configurationService.getRootConfigValues?.()[CopilotCliVSCodeAssignmentContextKey]; if (typeof value === 'string') { - this._vscodeAssignmentContext = value || undefined; + this._telemetryService.setExperimentProperty('abexp.assignmentcontext', value); } } @@ -1645,6 +1642,7 @@ export class CopilotAgent extends Disposable implements IAgent { } private async _routeGitHubTelemetry(notification: GitHubTelemetryNotification): Promise { + this._secondaryAssignmentContext.update(notification); const additionalProperties = { initiatorClientType: this._clientTypeForTelemetry(notification.sessionId) }; const router = this._githubTelemetryRouter; if (!router?.isTarget(notification)) { diff --git a/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts b/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts index cbeec003cee..af101cec430 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts @@ -204,7 +204,6 @@ export class CopilotGitHubTelemetryForwarder { constructor( private readonly _isRestrictedTelemetryEnabled: () => boolean, - private readonly _getVSCodeAssignmentContext: () => string | undefined, @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { } @@ -235,14 +234,6 @@ export class CopilotGitHubTelemetryForwarder { } } - // VS Code's TAS assignment context, scoped to forwarded Copilot CLI - // events only — deliberately not a telemetry-service-wide experiment - // property, so Claude/Codex/host events stay unstamped. - const assignmentContext = this._getVSCodeAssignmentContext(); - if (assignmentContext) { - data['abexp.assignmentcontext'] = assignmentContext; - } - if (event.features) { for (const [key, value] of Object.entries(event.features)) { if (value !== undefined) { diff --git a/src/vs/platform/agentHost/node/copilot/copilotSecondaryAssignmentContext.ts b/src/vs/platform/agentHost/node/copilot/copilotSecondaryAssignmentContext.ts new file mode 100644 index 00000000000..f371eeaae33 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotSecondaryAssignmentContext.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { GitHubTelemetryNotification } from '@github/copilot-sdk'; +import { isValidAssignmentContext } from '../../../telemetry/common/assignmentContext.js'; +import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; + +const SECONDARY_ASSIGNMENT_CONTEXT_PROPERTY = 'secondary_assignment_context'; + +// __GDPR__COMMON__ "secondary_assignment_context" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Secondary experiment assignment context assigned by CAPI during Copilot model calls." } + +export class CopilotSecondaryAssignmentContext { + + private _value: string | undefined; + + constructor( + @ITelemetryService private readonly _telemetryService: ITelemetryService, + ) { } + + update(notification: GitHubTelemetryNotification): void { + const value = notification.event.properties.secondary_assignment_context; + if (!value || value === this._value || !isValidAssignmentContext(value)) { + return; + } + + this._telemetryService.setExperimentProperty(SECONDARY_ASSIGNMENT_CONTEXT_PROPERTY, value); + this._value = value; + } +} diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index faed922fcf7..3c4ccc8f9f9 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -1118,63 +1118,61 @@ suite('CopilotAgent', () => { } }); - test('threads the assignment context from root config into forwarded CLI telemetry, sticky across a wipe', async () => { + test('promotes the forwarded secondary assignment context to a telemetry-wide property', async () => { const client = new TestCopilotClient([]); - const telemetryService = new class extends RecordingTelemetryService { - override publicLog(eventName?: string, data?: unknown): void { - this.events.push({ eventName: eventName ?? '', data }); - } - }(); - const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client, telemetryService }); + const telemetryService = new RecordingTelemetryService(); + const agent = createTestAgent(disposables, { copilotClient: client, telemetryService }) as TestableCopilotAgent; try { await agent.listChatsToMigrate(); const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; assert.ok(forward); - const notification = (sessionId: string): GitHubTelemetryNotification => ({ - sessionId, + await forward({ + sessionId: 'session', restricted: false, - event: { kind: 'response.success', properties: {}, metrics: {} }, + event: { + kind: 'response.success', + properties: { secondary_assignment_context: 'secondary:1' }, + metrics: {}, + exp_assignment_context: 'primary:1', + }, }); - configurationService.updateRootConfig({ [CopilotCliVSCodeAssignmentContextKey]: 'experiment:1' }); - await forward(notification('set')); - configurationService.updateRootConfig({}, true); - await forward(notification('wiped-sticky')); - configurationService.updateRootConfig({ [CopilotCliVSCodeAssignmentContextKey]: '' }); - await forward(notification('cleared')); - const expectedData = (sessionId: string, assignmentContext?: string) => ({ - created_at: undefined, - model_call_id: undefined, - exp_assignment_context: undefined, - session_id: sessionId, - sdk_session_id: sessionId, - copilot_tracking_id: undefined, - kind: 'response.success', - restricted: false, - ...(assignmentContext ? { 'abexp.assignmentcontext': assignmentContext } : {}), - }); - const events = telemetryService.events.map(event => { - if (event.eventName !== 'agentHost.copilotClientStartup') { - return event; - } - const data = event.data as Record; - return { ...event, data: { ...data, durationMs: typeof data.durationMs } }; - }); - assert.deepStrictEqual({ events, experimentProperties: telemetryService.experimentProperties }, { - events: [ - { eventName: 'agentHost.copilotClientStartup', data: { outcome: 'success', durationMs: 'number', attemptNumber: 1 } }, - { eventName: 'copilotSdk/response.success', data: expectedData('set', 'experiment:1') }, - { eventName: 'copilotSdk/response.success', data: expectedData('wiped-sticky', 'experiment:1') }, - { eventName: 'copilotSdk/response.success', data: expectedData('cleared') }, - ], - experimentProperties: {}, + assert.deepStrictEqual(telemetryService.experimentProperties, { + secondary_assignment_context: 'secondary:1', }); } finally { await disposeAgent(agent); } }); + test('promotes the VS Code assignment context from root config to telemetry, sticky across a wipe', async () => { + const client = new TestCopilotClient([]); + const telemetryService = new class extends RecordingTelemetryService { + readonly experimentPropertyUpdates: Array<{ name: string; value: string }> = []; + + override setExperimentProperty(name?: string, value?: string): void { + super.setExperimentProperty(name, value); + this.experimentPropertyUpdates.push({ name: name ?? '', value: value ?? '' }); + } + }(); + const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client, telemetryService }); + try { + await agent.listChatsToMigrate(); + + configurationService.updateRootConfig({ [CopilotCliVSCodeAssignmentContextKey]: 'experiment:1' }); + configurationService.updateRootConfig({}, true); + configurationService.updateRootConfig({ [CopilotCliVSCodeAssignmentContextKey]: '' }); + + assert.deepStrictEqual(telemetryService.experimentPropertyUpdates, [ + { name: 'abexp.assignmentcontext', value: 'experiment:1' }, + { name: 'abexp.assignmentcontext', value: '' }, + ]); + } finally { + await disposeAgent(agent); + } + }); + test('correlates forwarded response telemetry with active SDK session turns', async () => { const client = new TestCopilotClient([]); const telemetryService = new class extends RecordingTelemetryService { diff --git a/src/vs/platform/agentHost/test/node/copilotGitHubTelemetryForwarder.test.ts b/src/vs/platform/agentHost/test/node/copilotGitHubTelemetryForwarder.test.ts index 7954a1e19c8..58431480993 100644 --- a/src/vs/platform/agentHost/test/node/copilotGitHubTelemetryForwarder.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotGitHubTelemetryForwarder.test.ts @@ -41,7 +41,7 @@ suite('CopilotGitHubTelemetryForwarder', () => { test('forwards a standard event to VS Code telemetry', () => { const telemetryService = new TestTelemetryService(); - const forwarder = new CopilotGitHubTelemetryForwarder(() => false, () => undefined, telemetryService); + const forwarder = new CopilotGitHubTelemetryForwarder(() => false, telemetryService); forwarder.forward({ sessionId: 'notification-session', @@ -93,7 +93,7 @@ suite('CopilotGitHubTelemetryForwarder', () => { test('gates restricted events on the restricted telemetry option', () => { const telemetryService = new TestTelemetryService(); let restrictedTelemetryEnabled = false; - const forwarder = new CopilotGitHubTelemetryForwarder(() => restrictedTelemetryEnabled, () => undefined, telemetryService); + const forwarder = new CopilotGitHubTelemetryForwarder(() => restrictedTelemetryEnabled, telemetryService); const notification: GitHubTelemetryNotification = { sessionId: 'session', restricted: true, @@ -123,40 +123,9 @@ suite('CopilotGitHubTelemetryForwarder', () => { }]); }); - test('stamps VS Code assignment context independently of the runtime context', () => { - const telemetryService = new TestTelemetryService(); - const forwarder = new CopilotGitHubTelemetryForwarder(() => false, () => 'experiment:1;experiment:2', telemetryService); - - forwarder.forward({ - sessionId: 'session', - restricted: false, - event: { - kind: 'response.success', - properties: {}, - metrics: {}, - exp_assignment_context: 'runtime-context', - }, - }); - - assert.deepStrictEqual(telemetryService.events, [{ - eventName: 'copilotSdk/response.success', - data: { - created_at: undefined, - model_call_id: undefined, - exp_assignment_context: 'runtime-context', - session_id: 'session', - sdk_session_id: 'session', - copilot_tracking_id: undefined, - kind: 'response.success', - restricted: false, - 'abexp.assignmentcontext': 'experiment:1;experiment:2', - }, - }]); - }); - test('adds Agent Host turn correlation only to response events', () => { const telemetryService = new TestTelemetryService(); - const forwarder = new CopilotGitHubTelemetryForwarder(() => false, () => undefined, telemetryService); + const forwarder = new CopilotGitHubTelemetryForwarder(() => false, telemetryService); const notification = (kind: string, properties: Record = {}, metrics: Record = {}): GitHubTelemetryNotification => ({ sessionId: 'session', restricted: false, @@ -185,7 +154,7 @@ suite('CopilotGitHubTelemetryForwarder', () => { test('forwards tool_call_executed outcome and token-count columns', () => { const telemetryService = new TestTelemetryService(); - const forwarder = new CopilotGitHubTelemetryForwarder(() => false, () => undefined, telemetryService); + const forwarder = new CopilotGitHubTelemetryForwarder(() => false, telemetryService); forwarder.forward({ sessionId: 'session', diff --git a/src/vs/platform/agentHost/test/node/copilotSecondaryAssignmentContext.test.ts b/src/vs/platform/agentHost/test/node/copilotSecondaryAssignmentContext.test.ts new file mode 100644 index 00000000000..b2999320c8d --- /dev/null +++ b/src/vs/platform/agentHost/test/node/copilotSecondaryAssignmentContext.test.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { GitHubTelemetryNotification } from '@github/copilot-sdk'; +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullTelemetryServiceShape } from '../../../telemetry/common/telemetryUtils.js'; +import { CopilotSecondaryAssignmentContext } from '../../node/copilot/copilotSecondaryAssignmentContext.js'; + +class RecordingTelemetryService extends NullTelemetryServiceShape { + readonly experimentProperties: Array<{ name: string; value: string }> = []; + + override setExperimentProperty(name?: string, value?: string): void { + this.experimentProperties.push({ name: name ?? '', value: value ?? '' }); + } +} + +suite('CopilotSecondaryAssignmentContext', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const notification = (secondaryAssignmentContext?: string): GitHubTelemetryNotification => ({ + sessionId: 'session', + restricted: false, + event: { + kind: 'response.success', + properties: { secondary_assignment_context: secondaryAssignmentContext }, + metrics: {}, + }, + }); + + test('sets the telemetry-wide secondary assignment context from forwarded notifications', () => { + const telemetryService = new RecordingTelemetryService(); + const context = new CopilotSecondaryAssignmentContext(telemetryService); + + context.update(notification('secondary:1')); + context.update(notification('secondary:1')); + context.update(notification('secondary:2')); + + assert.deepStrictEqual(telemetryService.experimentProperties, [ + { name: 'secondary_assignment_context', value: 'secondary:1' }, + { name: 'secondary_assignment_context', value: 'secondary:2' }, + ]); + }); + + test('ignores a malformed secondary assignment context', () => { + const telemetryService = new RecordingTelemetryService(); + const context = new CopilotSecondaryAssignmentContext(telemetryService); + + context.update(notification('invalid')); + context.update(notification('secondary:1')); + + assert.deepStrictEqual(telemetryService.experimentProperties, [ + { name: 'secondary_assignment_context', value: 'secondary:1' }, + ]); + }); +}); diff --git a/src/vs/platform/telemetry/common/assignmentContext.ts b/src/vs/platform/telemetry/common/assignmentContext.ts new file mode 100644 index 00000000000..fb643728f85 --- /dev/null +++ b/src/vs/platform/telemetry/common/assignmentContext.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const MAX_ASSIGNMENT_CONTEXT_LENGTH = 8 * 1024; +const ASSIGNMENT_CONTEXT_ENTRY_PATTERN = /^[^:;\s\x00-\x1F\x7F]+:[^;\x00-\x1F\x7F]+$/; + +/** + * Validates an experiment assignment context before it is trusted onto telemetry events. + */ +export function isValidAssignmentContext(value: string): boolean { + if (value.length === 0 || value.length > MAX_ASSIGNMENT_CONTEXT_LENGTH) { + return false; + } + + const entries = value.endsWith(';') ? value.slice(0, -1).split(';') : value.split(';'); + return entries.length > 0 && entries.every(entry => ASSIGNMENT_CONTEXT_ENTRY_PATTERN.test(entry)); +} diff --git a/src/vs/workbench/api/browser/mainThreadTelemetry.ts b/src/vs/workbench/api/browser/mainThreadTelemetry.ts index ee1daa3a59f..a815ee1f84c 100644 --- a/src/vs/workbench/api/browser/mainThreadTelemetry.ts +++ b/src/vs/workbench/api/browser/mainThreadTelemetry.ts @@ -8,6 +8,7 @@ import { IConfigurationService } from '../../../platform/configuration/common/co import { CommandsRegistry } from '../../../platform/commands/common/commands.js'; import { IEnvironmentService } from '../../../platform/environment/common/environment.js'; import { IProductService } from '../../../platform/product/common/productService.js'; +import { isValidAssignmentContext } from '../../../platform/telemetry/common/assignmentContext.js'; import { ClassifiedEvent, IGDPRProperty, OmitMetadata, StrictPropertyCheck } from '../../../platform/telemetry/common/gdprTypings.js'; import { ITelemetryService, TelemetryLevel, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID, ITelemetryData } from '../../../platform/telemetry/common/telemetry.js'; import { supportsTelemetry } from '../../../platform/telemetry/common/telemetryUtils.js'; @@ -72,9 +73,6 @@ export const CAPI_ASSIGNMENT_CONTEXT_PROPERTY = 'capi.assignmentcontext'; */ export const SET_CAPI_ASSIGNMENT_CONTEXT_COMMAND = '_telemetry.setCapiAssignmentContext'; -const MAX_CAPI_ASSIGNMENT_CONTEXT_LENGTH = 8 * 1024; -const CAPI_ASSIGNMENT_CONTEXT_ENTRY_PATTERN = /^[^:;\s\x00-\x1F\x7F]+:[^;\x00-\x1F\x7F]+$/; - /** * Validates a CAPI assignment-context string before it is trusted onto every * core telemetry event. Because {@link ITelemetryService.setExperimentProperty} @@ -84,13 +82,7 @@ const CAPI_ASSIGNMENT_CONTEXT_ENTRY_PATTERN = /^[^:;\s\x00-\x1F\x7F]+:[^;\x00-\x * malformed input is rejected outright. */ export function isValidCapiAssignmentContext(value: string): boolean { - if (value.length === 0 || value.length > MAX_CAPI_ASSIGNMENT_CONTEXT_LENGTH) { - return false; - } - - // Tolerate a single trailing separator (`a:b;`) but nothing else empty. - const entries = value.endsWith(';') ? value.slice(0, -1).split(';') : value.split(';'); - return entries.length > 0 && entries.every(entry => CAPI_ASSIGNMENT_CONTEXT_ENTRY_PATTERN.test(entry)); + return isValidAssignmentContext(value); } CommandsRegistry.registerCommand(SET_CAPI_ASSIGNMENT_CONTEXT_COMMAND, function (accessor, value: string) { From a1a7ded83c5fef453dd65ffd76399c9b29ce144a Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Wed, 26 Aug 2026 11:56:48 -0400 Subject: [PATCH 057/116] Bump distro commit to ebabac9 in package.json (#332743) Update distro commit to ebabac9a Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index add5332642d..a5f37737467 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.136.0", - "distro": "49ca65f8fe95b1a7f3253d817261df69638c258c", + "distro": "ebabac9aa9cfb4c9351977aa0d1cab83b357ffaa", "author": { "name": "Microsoft Corporation" }, From c59d39258912d3f272fb78179996a7afb1e9804a Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:58:12 -0700 Subject: [PATCH 058/116] set some chat settings to true by default (#332756) --- .../contrib/chat/browser/chat.shared.contribution.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 279687b25a3..5f049772601 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -565,7 +565,7 @@ configurationRegistry.registerConfiguration({ [ChatConfiguration.CollapseCompletedResponses]: { type: 'boolean', description: nls.localize('chat.agent.collapseCompletedResponses', "Controls whether completed chat responses collapse intermediate work while keeping the final response visible."), - default: product.quality !== 'stable', + default: true, }, 'chat.detectParticipant.enabled': { type: 'boolean', @@ -575,7 +575,7 @@ configurationRegistry.registerConfiguration({ [ChatConfiguration.ExperimentalStickyScrollEnabled]: { type: 'boolean', description: nls.localize('chat.experimental.stickyScroll.enabled', "Controls whether chat requests use experimental tree-based sticky scroll instead of the sticky prompt header."), - default: product.quality === 'insider', + default: true, tags: ['experimental'], }, [ChatConfiguration.InlineReferencesStyle]: { From 0e2b4bfe6485878e7836528c3d3a8021d4ede6c8 Mon Sep 17 00:00:00 2001 From: Giuseppe Cianci <39117631+Giuspepe@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:58:16 +0200 Subject: [PATCH 059/116] agent host: start persistent Codex app-server only for Codex sessions (#332689) * agentHost: make Codex app-server lifecycle demand-driven Probe ChatGPT account state through a bounded one-off connection, and retain a Codex app-server only after a session is created or restored. Harden chat lifecycle, reconnect cleanup, catalog refresh ordering, and passive registry metadata so demand-driven activation preserves existing behavior. * agentHost: update disconnected chat test for durable errors Read the error from the durable response part introduced on main while preserving the reconnect regression assertion. * agentHost: preserve registry session recency Persist session modification times and use them for lazy-provider fallback ordering so restored sessions retain stable recency. Harden the associated tests, including platform-native Windows path expectations. * agentHost: refresh Codex models after activation An ambient refresh can already have skipped subscription models when Codex crosses its activation boundary. Queue a serialized follow-up refresh so new and restored Codex sessions publish the ChatGPT catalog. --- src/vs/platform/agentHost/common/agent.ts | 12 +- .../agentHost/node/agentHostDatabase.ts | 36 +- .../platform/agentHost/node/agentService.ts | 69 +- .../agentHost/node/agentSessionRegistry.ts | 9 + .../agentHost/node/codex/codexAgent.ts | 1647 +++++++++++++---- .../agentHost/test/node/agentService.test.ts | 217 ++- .../test/node/agentSessionRegistry.test.ts | 45 +- .../test/node/codex/codexAgent.test.ts | 38 +- .../test/node/codex/codexCreateChat.test.ts | 1185 +++++++++++- .../test/node/codex/codexModelRefresh.test.ts | 866 ++++++++- .../node/codex/codexPrewarmEviction.test.ts | 366 +++- .../node/codex/codexSessionConfigKeys.test.ts | 4 +- .../node/codex/codexSessionTitleSpans.test.ts | 3 +- 13 files changed, 3981 insertions(+), 516 deletions(-) diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index c5653860ae4..1ff7931c47a 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -140,6 +140,14 @@ export interface IAgentChatMetadata { readonly _meta?: SessionMeta; } +/** Identifies metadata reads that may initialize an otherwise lazy provider. */ +export interface IAgentChatMetadataOptions { + /** A session restore needs authoritative provider data and may start its runtime. */ + readonly activation?: 'restore'; + /** Stable host-owned timestamps a lazy provider may use for passive catalogue metadata. */ + readonly registryFallback?: Pick; +} + /** A provider chat ready to be registered as an Agent Host session. */ export interface IAgentDiscoveredChat extends IAgentChatMetadata { readonly external: boolean; @@ -1203,8 +1211,8 @@ export interface IAgent { // ---- Metadata ----------------------------------------------------------- - /** Retrieve metadata for an exact registered chat. */ - getChatMetadata(chat: URI, context: URI | IAgentChatContext, providerData?: string): Promise; + /** Retrieve metadata for an exact registered chat. Ambient catalogue reads never set {@link IAgentChatMetadataOptions.activation}. */ + getChatMetadata(chat: URI, context: URI | IAgentChatContext, providerData?: string, options?: IAgentChatMetadataOptions): Promise; // ---- Authentication and diagnostics ------------------------------------ diff --git a/src/vs/platform/agentHost/node/agentHostDatabase.ts b/src/vs/platform/agentHost/node/agentHostDatabase.ts index 32ae0e953b1..b2fc3c0f64c 100644 --- a/src/vs/platform/agentHost/node/agentHostDatabase.ts +++ b/src/vs/platform/agentHost/node/agentHostDatabase.ts @@ -22,6 +22,7 @@ export interface IAgentHostDatabaseSession { readonly session: string; readonly provider: AgentProvider; readonly startTime: number; + readonly modifiedTime: number; readonly external: boolean | undefined; readonly source: AgentSessionRegistrationSource; } @@ -29,6 +30,8 @@ export interface IAgentHostDatabaseSession { export interface IAgentHostDatabaseSessionOptions { readonly provider: AgentProvider; readonly startTime: number; + /** Last observed provider modification time; defaults to {@link startTime}. */ + readonly modifiedTime?: number; readonly source: AgentSessionRegistrationSource; } @@ -51,6 +54,8 @@ export interface IAgentHostDatabase extends IDisposable { /** Atomically tombstones and removes a session so concurrent backfill cannot re-register it. */ tombstoneAndUnregisterSession(session: string): Promise; updateSessionExternal(updates: readonly IAgentHostDatabaseExternalUpdate[]): Promise; + /** Advances the durable last-observed modification time. */ + updateSessionModifiedTime(session: string, modifiedTime: number): Promise; getSession(session: string): Promise; listSessions(): Promise; isSessionRegistryEmpty(): Promise; @@ -109,6 +114,13 @@ const migrations = [ `UPDATE sessions SET registration_source = CASE WHEN external = 1 THEN 'discovery' ELSE 'explicit' END`, ].join(';\n'), }, + { + version: 4, + sql: [ + 'ALTER TABLE sessions ADD COLUMN modified_time INTEGER NOT NULL DEFAULT 0', + 'UPDATE sessions SET modified_time = start_time', + ].join(';\n'), + }, ] as const; function openDatabase(path: string): Promise { @@ -185,14 +197,15 @@ export class AgentHostDatabase implements IAgentHostDatabase { constructor(private readonly _path: string) { } async registerSession(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise { - const { provider, startTime, source } = sessionOptions; + const { provider, startTime, modifiedTime = startTime, source } = sessionOptions; const changes = await runReturningChanges( await this._ensureDatabase(), - `INSERT INTO sessions (session_uri, provider, start_time, external, registration_source) - SELECT ?, ?, ?, CASE WHEN ? = 'discovery' THEN 1 ELSE 0 END, ? + `INSERT INTO sessions (session_uri, provider, start_time, modified_time, external, registration_source) + SELECT ?, ?, ?, ?, CASE WHEN ? = 'discovery' THEN 1 ELSE 0 END, ? WHERE ? = 0 OR NOT EXISTS (SELECT 1 FROM metadata WHERE key = ? AND value = 'true') ON CONFLICT(session_uri) DO UPDATE SET provider = CASE WHEN excluded.registration_source = 'explicit' THEN excluded.provider ELSE sessions.provider END, + modified_time = MAX(sessions.modified_time, excluded.modified_time), external = CASE WHEN excluded.registration_source = 'explicit' THEN 0 WHEN excluded.registration_source = 'restore' THEN 0 @@ -204,7 +217,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { WHEN sessions.registration_source = 'explicit' THEN 'explicit' ELSE excluded.registration_source END`, - [session, provider, startTime, source, source, registerOptions.checkTombstone ? 1 : 0, tombstoneKey(session)], + [session, provider, startTime, modifiedTime, source, source, registerOptions.checkTombstone ? 1 : 0, tombstoneKey(session)], ); if (!registerOptions.checkTombstone) { await this.clearSessionTombstone(session); @@ -280,19 +293,29 @@ export class AgentHostDatabase implements IAgentHostDatabase { } } + async updateSessionModifiedTime(session: string, modifiedTime: number): Promise { + const changes = await runReturningChanges( + await this._ensureDatabase(), + 'UPDATE sessions SET modified_time = ? WHERE session_uri = ? AND modified_time < ?', + [modifiedTime, session, modifiedTime], + ); + return changes > 0; + } + async listSessions(): Promise { - const rows = await all(await this._ensureDatabase(), 'SELECT session_uri, provider, start_time, external, registration_source FROM sessions', []); + const rows = await all(await this._ensureDatabase(), 'SELECT session_uri, provider, start_time, modified_time, external, registration_source FROM sessions', []); return rows.map(row => ({ session: row.session_uri as string, provider: row.provider as AgentProvider, startTime: row.start_time as number, + modifiedTime: row.modified_time as number, external: row.external === null ? undefined : row.external === 1, source: row.registration_source as AgentSessionRegistrationSource, })); } async getSession(session: string): Promise { - const row = await get(await this._ensureDatabase(), 'SELECT session_uri, provider, start_time, external, registration_source FROM sessions WHERE session_uri = ?', [session]); + const row = await get(await this._ensureDatabase(), 'SELECT session_uri, provider, start_time, modified_time, external, registration_source FROM sessions WHERE session_uri = ?', [session]); if (!row) { return undefined; } @@ -300,6 +323,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { session: row.session_uri as string, provider: row.provider as AgentProvider, startTime: row.start_time as number, + modifiedTime: row.modified_time as number, external: row.external === null || row.external === undefined ? undefined : row.external === 1, source: row.registration_source as AgentSessionRegistrationSource, }; diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 17e62dceebd..9ecab6b4d65 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -438,6 +438,8 @@ export class AgentService extends Disposable implements IAgentService { */ private readonly _sessionRegistry: AgentSessionRegistry; private readonly _orchestratorDatabase: IAgentHostDatabase; + /** Serializes durable last-modified advances emitted by live session state. */ + private _sessionModifiedTimeWrites: Promise = Promise.resolve(); private readonly _providerMigrations = new Map(); private readonly _initialProviderMigrations = new Map>(); @@ -671,6 +673,9 @@ export class AgentService extends Disposable implements IAgentService { this._register(this._stateManager.onDidRemoveSession(session => this._pendingAgentMergeNotices.delete(session))); this._register(this._stateManager.onDidChangeSessionSummary(({ session, changes }) => { const meta = this._stateManager.getSessionSummary(session)?._meta; + if (changes.modifiedAt !== undefined) { + this._writeSessionModifiedTime(URI.parse(session), Date.parse(changes.modifiedAt)); + } if (changes.modifiedAt !== undefined && this._getExternalSessionsMode() === AgentHostExternalSessionsMode.Recent && readSessionExternal(meta) @@ -1359,16 +1364,27 @@ export class AgentService extends Disposable implements IAgentService { } /** - * Registry metadata for one session. Returns `undefined` when the agent - * cannot describe the session yet; {@link listSessions} still overlays - * active provisional sessions from state-manager data. + * Registry metadata for one session. The host offers its stable timestamps + * as a fallback, but the provider decides whether a passive metadata miss + * means "not initialized yet" or "not found". */ - private async _registeredSessionMetadata(agent: IAgent, session: URI, external: boolean): Promise { + private async _registeredSessionMetadata(agent: IAgent, session: URI, external: boolean, fallback?: Pick): Promise { const chat = URI.parse(buildDefaultChatUri(session)); - const metadata = await agent.getChatMetadata(chat, this._chatContext(session, chat), await this._readDefaultChatProviderData(session)); + const metadata = await agent.getChatMetadata( + chat, + this._chatContext(session, chat), + await this._readDefaultChatProviderData(session), + fallback ? { registryFallback: { startTime: fallback.startTime, modifiedTime: fallback.modifiedTime } } : undefined, + ); if (!metadata) { return undefined; } + if (fallback && metadata.modifiedTime > fallback.modifiedTime) { + // This computation already returns the fresher metadata, and settled + // list computations are not cached. Persist without invalidating the + // in-flight computation into a redundant second pass. + await this._advanceSessionModifiedTime(session, metadata.modifiedTime, false); + } const sessionMetadata = this._toSessionMetadata(metadata); return { ...sessionMetadata, @@ -1385,7 +1401,7 @@ export class AgentService extends Disposable implements IAgentService { const liveSummary = this._stateManager.getSessionSummary(session.toString()); if (liveSummary) { const metadata = (liveSummary.workingDirectories === undefined && agent - ? await this._registeredSessionMetadata(agent, session, registered.external) + ? await this._registeredSessionMetadata(agent, session, registered.external, registered) : undefined) ?? { session, startTime: registered.startTime, @@ -1396,7 +1412,7 @@ export class AgentService extends Disposable implements IAgentService { if (!agent) { return undefined; } - return this._registeredSessionMetadata(agent, session, registered.external); + return this._registeredSessionMetadata(agent, session, registered.external, registered); } private _withLiveSessionMetadata(metadata: IAgentSessionMetadata, liveSummary: SessionSummary): IAgentSessionMetadata { @@ -1668,9 +1684,11 @@ export class AgentService extends Disposable implements IAgentService { const sessionMetadata = this._toSessionMetadata(metadata); const session = sessionMetadata.session; try { - // Matching registry entries need no per-session I/O. + // Matching registry entries still advance their durable recency from + // the provider catalog, but need no per-session metadata I/O. if (registeredKeys.has(session.toString())) { alreadyRegistered++; + await this._advanceSessionModifiedTime(session, sessionMetadata.modifiedTime); return false; } if (isSubagentSession(session.toString()) || await this._isChatBacking(session)) { @@ -1681,7 +1699,7 @@ export class AgentService extends Disposable implements IAgentService { skippedAsStale++; return false; } - const identity: IRegisteredSession = { session, provider: provider.id, startTime: metadata.startTime, external, source: external ? 'discovery' : 'restore' }; + const identity: IRegisteredSession = { session, provider: provider.id, startTime: metadata.startTime, modifiedTime: metadata.modifiedTime, external, source: external ? 'discovery' : 'restore' }; const registered = await this._retryRegistryMutation( () => this._sessionRegistry.register(session, identity, { checkTombstone: true }), `discovery registration for ${session.toString()}`, @@ -1750,7 +1768,7 @@ export class AgentService extends Disposable implements IAgentService { return undefined; } const external = !facts.hostCreated; - return { session: s.session, provider: provider.id, startTime: s.startTime, external, source: external ? 'discovery' : 'restore' }; + return { session: s.session, provider: provider.id, startTime: s.startTime, modifiedTime: s.modifiedTime, external, source: external ? 'discovery' : 'restore' }; }))); let registeredExternal = false; const untitledExternal: IAgentSessionMetadata[] = []; @@ -1854,6 +1872,25 @@ export class AgentService extends Disposable implements IAgentService { return this._sessionRegistry.list(entry => this._migrateRegisteredSession(entry)); } + private async _advanceSessionModifiedTime(session: URI, modifiedTime: number, invalidate = true): Promise { + if (!Number.isFinite(modifiedTime)) { + return; + } + const changed = await this._retryRegistryMutation( + () => this._sessionRegistry.updateModifiedTime(session, modifiedTime), + `modified-time update for ${session.toString()}`, + ); + if (changed && invalidate) { + this._invalidateSessionList(); + } + } + + private _writeSessionModifiedTime(session: URI, modifiedTime: number): void { + this._sessionModifiedTimeWrites = this._sessionModifiedTimeWrites + .then(() => this._advanceSessionModifiedTime(session, modifiedTime)) + .catch(err => this._logService.warn(`[AgentService] Failed to persist the modified time for ${session.toString()}`, err)); + } + private async _retryRegistryMutation(operation: () => Promise, description: string): Promise { try { return await operation(); @@ -1977,7 +2014,7 @@ export class AgentService extends Disposable implements IAgentService { return undefined; } try { - return await this._registeredSessionMetadata(agent, session, external); + return await this._registeredSessionMetadata(agent, session, external, registeredSession); } catch (err) { this._logService.warn(`[AgentService] listSessions: failed to read metadata for ${session}`, err); return undefined; @@ -2654,8 +2691,9 @@ export class AgentService extends Disposable implements IAgentService { } } else { try { + const registeredAt = Date.now(); await this._retryRegistryMutation( - () => this._sessionRegistry.register(session, { provider: provider.id, startTime: Date.now(), source: 'explicit' }, { checkTombstone: false }), + () => this._sessionRegistry.register(session, { provider: provider.id, startTime: registeredAt, modifiedTime: registeredAt, source: 'explicit' }, { checkTombstone: false }), `registration for ${session.toString()}`, ); this._invalidateSessionList(); @@ -4791,8 +4829,9 @@ export class AgentService extends Disposable implements IAgentService { // list at all. A registration that cannot be made durable fails the // migration: continuing would leave exactly the orphan this prevents. if (adopted && !registeredSession) { + const registeredAt = Date.now(); await this._retryRegistryMutation( - () => this._sessionRegistry.register(session, { provider: agent.id, startTime: Date.now(), source: 'restore' }, { checkTombstone: true }), + () => this._sessionRegistry.register(session, { provider: agent.id, startTime: registeredAt, modifiedTime: registeredAt, source: 'restore' }, { checkTombstone: true }), `adoption registration for ${sessionStr}`, ); registeredAfterAdoption = true; @@ -5160,7 +5199,7 @@ export class AgentService extends Disposable implements IAgentService { : defaultDraft; const mergedTurns = await this._interleaveLocalTurns(sessionStr, defaultChatUri.toString(), turns); const registered = await this._retryRegistryMutation( - () => this._sessionRegistry.register(session, { provider: agent.id, startTime: meta.startTime, source: registrationSource }, { checkTombstone: true }), + () => this._sessionRegistry.register(session, { provider: agent.id, startTime: meta.startTime, modifiedTime: meta.modifiedTime, source: registrationSource }, { checkTombstone: true }), `registration for restored session ${session.toString()}`, ); if (!registered) { @@ -5752,7 +5791,7 @@ export class AgentService extends Disposable implements IAgentService { const sessionStr = session.toString(); const chat = URI.parse(buildDefaultChatUri(session)); try { - const metadata = await agent.getChatMetadata(chat, this._chatContext(session, chat), await this._readDefaultChatProviderData(session)); + const metadata = await agent.getChatMetadata(chat, this._chatContext(session, chat), await this._readDefaultChatProviderData(session), { activation: 'restore' }); return await this._withWorktreeProject(session, metadata ? this._toSessionMetadata(metadata) : undefined); } catch (err) { if (err instanceof ProtocolError) { diff --git a/src/vs/platform/agentHost/node/agentSessionRegistry.ts b/src/vs/platform/agentHost/node/agentSessionRegistry.ts index f2dc872ed5e..7bc944a0371 100644 --- a/src/vs/platform/agentHost/node/agentSessionRegistry.ts +++ b/src/vs/platform/agentHost/node/agentSessionRegistry.ts @@ -15,6 +15,8 @@ export interface IRegisteredSession { readonly provider: AgentProvider; /** Session creation time (ms since epoch) as first observed by the orchestrator. */ readonly startTime: number; + /** Most recent provider modification time observed by the orchestrator. */ + readonly modifiedTime: number; /** Whether the session was first discovered from the provider's native catalog. */ readonly external: boolean; /** Durable registration source used to protect external provenance. */ @@ -85,6 +87,11 @@ export class AgentSessionRegistry extends Disposable { await this._database.tombstoneAndUnregisterSession(session.toString()); } + /** Advances the durable last-observed provider modification time. */ + updateModifiedTime(session: URI, modifiedTime: number): Promise { + return this._database.updateSessionModifiedTime(session.toString(), modifiedTime); + } + /** Every registered session URI key without running legacy metadata migration. */ async listSessionKeys(): Promise> { return new Set((await this._database.listSessions()).map(entry => entry.session)); @@ -99,6 +106,7 @@ export class AgentSessionRegistry extends Disposable { session: URI.parse(entry.session), provider: entry.provider, startTime: entry.startTime, + modifiedTime: entry.modifiedTime, external: entry.external, source: entry.source, })); @@ -140,6 +148,7 @@ export class AgentSessionRegistry extends Disposable { session: URI.parse(stored.session), provider: stored.provider, startTime: stored.startTime, + modifiedTime: stored.modifiedTime, external: stored.external, source: stored.source, }; diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 1d9af8203a1..410f2eeacba 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -7,7 +7,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import { CancellationError } from '../../../../base/common/errors.js'; -import { disposableTimeout, Limiter, raceTimeout, retry, Sequencer } from '../../../../base/common/async.js'; +import { DeferredPromise, disposableTimeout, Limiter, raceCancellationError, raceTimeout, retry, Sequencer, SequencerByKey } from '../../../../base/common/async.js'; import { fetchResourceMetadata } from '../../../../base/common/oauth.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; @@ -28,7 +28,7 @@ import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../co import { AgentSdkSetupChannel } from '../agentSdkSetupChannel.js'; import { CODEX_ACCOUNT_META_KEY, CODEX_ACCOUNT_SIGN_IN_REQUEST_KEY, CODEX_ACCOUNT_SIGN_OUT_REQUEST_KEY, type ICodexAccountInfo } from '../../common/codexAccount.js'; import { getReasoningEffortDescription, getReasoningEffortLabel, resolveDefaultReasoningEffort } from '../../common/reasoningEffort.js'; -import { AgentSession, AgentSignal, CODEX_AGENT_PROVIDER_ID, IActiveClient, IAgent, IAgentChatConfigCompletionsParams, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentChats, IAgentCreateChatForkSource, IAgentCreateChatResult, IAgentCreateChatOptions, IAgentDescriptor, IAgentDiscoveredChat, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveChatConfigParams, IAgentSpawnChatEvent, IMcpNotification, resolveAgentChatContext, resolveAgentHostInstructions, type AgentProvider, type AuthenticateParams } from '../../common/agent.js'; +import { AgentSession, AgentSignal, CODEX_AGENT_PROVIDER_ID, IActiveClient, IAgent, IAgentChatConfigCompletionsParams, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, type IAgentChatMetadataOptions, IAgentChats, IAgentCreateChatForkSource, IAgentCreateChatResult, IAgentCreateChatOptions, IAgentDescriptor, IAgentDiscoveredChat, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveChatConfigParams, IAgentSpawnChatEvent, IMcpNotification, resolveAgentChatContext, resolveAgentHostInstructions, type AgentProvider, type AuthenticateParams } from '../../common/agent.js'; import { AgentHostCodexAgentBinaryArgsEnvVar, AgentHostCodexAgentCodexHomeEnvVar, AgentHostCodexAgentSdkRootEnvVar } from '../../common/agentService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; @@ -69,7 +69,7 @@ import { extractForwardedErrorInfo } from '../shared/proxyChatError.js'; import { IAgentHostWorktreeIsolation, type IAgentHostWorktreePendingState } from '../shared/worktreeIsolation.js'; import { getServerToolDisplay } from '../shared/serverToolGroups.js'; import { IAgentSdkDownloader, IAgentSdkPackage } from '../agentSdkDownloader.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { CodexAppServerClient, JsonRpcError, transportFromChildProcess, type ICodexAppServerClient, type ServerRequestHandlerResult } from './codexAppServerClient.js'; @@ -158,6 +158,7 @@ const CLIENT_INFO = { const CODEX_DESKTOP_ROLLOUT_PREFIX_LENGTH = 16 * 1024; const CODEX_DESKTOP_ROLLOUT_PREFIX_CONCURRENCY = 8; const CODEX_COLD_SESSION_READ_CONCURRENCY = 8; +const CODEX_STARTUP_ACCOUNT_PROBE_TIMEOUT_MS = 30_000; const CODEX_DESKTOP_WORKSPACE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; const CODEX_DESKTOP_SESSION_META_PATTERN = /"type"\s*:\s*"session_meta".*"payload"\s*:\s*\{[^}]*"originator"\s*:\s*"Codex Desktop"/s; @@ -692,8 +693,10 @@ interface ICodexSession { prewarmTimer: ReturnType | undefined; /** True once the prewarmed session has been claimed by a user turn. */ prewarmClaimed: boolean; - /** True once the agent host's server tools have been advertised on this session. */ - serverToolsAdvertised: boolean; + /** Configuration resource on which the agent host's server tools were most recently advertised. */ + serverToolsAdvertisement: string | undefined; + /** Directory customization ids last published for workspace agents, skills, and hooks. */ + readonly publishedDirectoryCustomizationIds: Set; /** * Per-session MCP customization surface. Created lazily the first time * the session needs to surface codex's MCP servers (either via @@ -752,19 +755,27 @@ interface ICodexSubagent { } /** - * Connection state machine. The codex process is spawned on first need — - * including eager model enumeration when persisted ChatGPT auth is detected — - * and stays alive for the agent's lifetime. + * Persistent connection state machine. The Codex process is retained only + * after a Codex-backed session is created or restored. */ type ConnectionState = | { readonly kind: 'idle' } - | { readonly kind: 'starting'; readonly promise: Promise } + | { readonly kind: 'starting'; readonly promise: Promise; readonly cancellation: CancellationTokenSource } | ({ readonly kind: 'ready' } & IConnectionReady); interface IConnectionReady { readonly client: ICodexAppServerClient; readonly proxyHandle: ICodexProxyHandle; readonly child: ChildProcessWithoutNullStreams; + /** Event/request registrations owned by this particular persistent client. */ + readonly subscriptions?: DisposableStore; +} + +/** Internal retry signal for work that was prepared against a replaced app-server. */ +class CodexConnectionReplacedError extends Error { + constructor(message = 'Codex app-server was replaced during a thread operation') { + super(message); + } } interface ICodexCustomizationLaunch { @@ -937,6 +948,12 @@ class CodexActiveClientHandle implements IActiveClient { } } + /** Cache customizations whose initial async sync was awaited by the creator. */ + commitCustomizations(customizations: readonly ClientPluginCustomization[]): void { + this._customizations = customizations; + this._customizationsRevision++; + } + remove(): void { this._customizationsRevision++; const session = this._resolveSession(); @@ -993,7 +1010,9 @@ export class CodexAgent extends Disposable implements IAgent { private readonly _desktopRolloutPrefixLimiter = this._register(new Limiter(CODEX_DESKTOP_ROLLOUT_PREFIX_CONCURRENCY)); private readonly _coldSessionReadLimiter = this._register(new Limiter(CODEX_COLD_SESSION_READ_CONCURRENCY)); private _openAIAccountState: ICodexAccountState = { usageSource: 'openai', status: 'unknown' }; + private readonly _accountRefreshSequencer = new Sequencer(); private _openAIAccountRateLimit: ICodexAccountInfo['rateLimit']; + private _openAIAccountRateLimitRequest = 0; private _openAIAccountProfileImage: ICodexAccountInfo['profileImage']; private _openAIAccountProfileImageRequest = 0; private _profileImageStore: CodexProfileImageStore | undefined; @@ -1041,6 +1060,8 @@ export class CodexAgent extends Disposable implements IAgent { private readonly _mcpPublisherSessionIdByConfiguration = new Map(); private readonly _publishedMcpTopLevelIdsByConfiguration = new Map>(); private readonly _customizationReconcileSequencers = new WeakMap(); + private readonly _directoryCustomizationSequencers = new WeakMap(); + private readonly _skillExtraRootsSequencer = new Sequencer(); private readonly _sessionMcpDiscoveries = new Map(); private readonly _pendingMcpStartupStatuses = new Map>(); /** @@ -1068,14 +1089,39 @@ export class CodexAgent extends Disposable implements IAgent { private _gitHubMcpServerConfiguration: IMcpServerConfiguration | undefined; private _githubAuthenticationGeneration = 0; private _githubMcpServerEnabled = true; + /** + * Whether the user has explicitly entered a Codex flow in this host process. + * Provider registration, authentication replay, and ambient session listing + * must not start the app-server: doing so can use a shared ChatGPT login and + * contact OpenAI before the user selects Codex. + */ + private _activated = false; + private _isShuttingDown = false; private _connection: ConnectionState = { kind: 'idle' }; private _connectionGeneration = 0; + /** Makes cleanup idempotent across shutdown and connection-loss races. */ + private readonly _disposedConnections = new WeakSet(); + /** Serializes persistent startup behind the one-off account probe. */ + private readonly _startupAccountProbe = new DeferredPromise(); + /** Cancels startup before a partially initialized one-off process can outlive this agent. */ + private readonly _startupAccountProbeCancellation = this._register(new CancellationTokenSource()); + /** One-off account/catalogue actions share one process at a time. */ + private readonly _onDemandConnectionSequencer = new Sequencer(); + /** Orders create/release/dispose so one exact chat cannot race its own backing lifecycle. */ + private readonly _chatLifecycleSequencer = new SequencerByKey(); + /** Settles when the currently-running one-off action has released its process. */ + private _transientConnectionOperation: Promise | undefined; + /** A deliberately short-lived connection used by an explicit account action. */ + private _transientAccountConnection: IConnectionReady | undefined; + /** Owns a one-off connection even while its initialize handshake is pending. */ + private _transientConnectionCancellation: CancellationTokenSource | undefined; private readonly _onDidDiscoverChats = this._register(new Emitter({ onDidAddFirstListener: () => { void this._startCodexChatDiscovery(); }, })); readonly onDidDiscoverChats = this._onDidDiscoverChats.event; private _codexChatDiscovery: Promise | undefined; private _modelsRefreshPromise: Promise | undefined; + private readonly _modelRefreshSequencer = new Sequencer(); /** * Bounded retry for transient Copilot catalog failures. Without this, a * failed request triggered by sign-in leaves the picker stale until another @@ -1084,7 +1130,9 @@ export class CodexAgent extends Disposable implements IAgent { protected readonly _modelRefreshMaxAttempts = MODEL_REFRESH_MAX_ATTEMPTS; protected readonly _modelRefreshBaseDelayMs = MODEL_REFRESH_BASE_DELAY_MS; protected readonly _modelRefreshMaxDelayMs = MODEL_REFRESH_MAX_DELAY_MS; + protected readonly _startupAccountProbeTimeoutMs = CODEX_STARTUP_ACCOUNT_PROBE_TIMEOUT_MS; private readonly _modelRefreshRetry = this._register(new MutableDisposable()); + private readonly _skillHookCustomizationRefresh = this._register(new MutableDisposable()); /** Invalidates retries that belong to an older Copilot token or endpoint. */ private _modelCatalogGeneration = 0; private _copilotModels: readonly IAgentModelInfo[] = []; @@ -1184,8 +1232,6 @@ export class CodexAgent extends Disposable implements IAgent { this._startModelRefreshWhenSdkIsLocal(); this._queueProviderConfigurationWrite(); })); - void this._refreshProviderConfiguration(); - this._startModelRefreshWhenSdkIsLocal(); this._sdkSetupChannel = this._register(new AgentSdkSetupChannel({ id: this.id, sdkPackage: CodexSdkPackage, @@ -1201,6 +1247,13 @@ export class CodexAgent extends Disposable implements IAgent { restartChatDiscovery: () => this._restartChatDiscovery(), refreshModels: () => this.refreshModels(), }, this._configurationService, this._agentSdkDownloader, this._logService)); + queueMicrotask(async () => { + try { + await this._probeAccountAtStartup(); + } finally { + await this._startupAccountProbe.complete(undefined); + } + }); } /** @@ -1221,6 +1274,7 @@ export class CodexAgent extends Disposable implements IAgent { && previousState.email === state.email; if (!sameChatGPTAccount) { this._openAIAccountRateLimit = undefined; + this._openAIAccountRateLimitRequest++; this._openAIAccountProfileImage = undefined; void this._profileImageStore?.clear(); this._openAIAccountProfileImageRequest++; @@ -1240,15 +1294,61 @@ export class CodexAgent extends Disposable implements IAgent { if (!(await this._isSdkResolvableWithoutDownload())) { this._publishAccountInfo({ status: 'downloading' }); } - const connection = await this._ensureConnection(); - const account = await this._refreshAccount(connection.client); - if (account.status === 'signedIn' && account.authType === 'chatgpt') { - return; - } - const response = await connection.client.request<'account/login/start', LoginAccountResponse>('account/login/start', { type: 'chatgpt' }); - if (response.type === 'chatgpt') { - this._publishAccountInfo({ ...this._toAccountInfo(this._openAIAccountState), authUrl: response.authUrl, authUrlNonce: request }); - } + await this._withOnDemandConnection(async (client, transient) => { + const account = await this._refreshAccount(client, true, transient); + if (account.status === 'signedIn' && account.authType === 'chatgpt') { + return; + } + + // A standalone sign-in connection lives only until this login attempt + // completes. A session-owned connection already has its permanent + // account/login/completed handler, so it can return after publishing the URL. + const loginCompleted = new DeferredPromise<{ readonly success: boolean; readonly error: string | null }>(); + let loginId: string | undefined; + let earlyLoginCompletions: Array<{ readonly loginId: string | null; readonly success: boolean; readonly error: string | null }> = []; + const completionListener = transient ? client.onNotification('account/login/completed', params => { + if (loginId === undefined) { + earlyLoginCompletions.push(params); + return; + } + if (params.loginId !== loginId) { + return; + } + void loginCompleted.complete(params); + }) : undefined; + try { + const response = await client.request<'account/login/start', LoginAccountResponse>('account/login/start', { type: 'chatgpt' }); + if (response.type !== 'chatgpt') { + return; + } + loginId = response.loginId; + const earlyCompletion = earlyLoginCompletions.find(completion => completion.loginId === loginId); + earlyLoginCompletions = []; + if (earlyCompletion) { + await loginCompleted.complete(earlyCompletion); + } + // A persistent connection's global completion handler can finish its + // account refresh before login/start returns. Do not put the obsolete + // authorization URL back onto an account that is already signed in. + if (this._openAIAccountState.status === 'signedIn' && this._openAIAccountState.authType === 'chatgpt') { + this._publishAccountInfo(this._toAccountInfo(this._openAIAccountState)); + return; + } + this._publishAccountInfo({ ...this._toAccountInfo(this._openAIAccountState), authUrl: response.authUrl, authUrlNonce: request }); + if (transient) { + const result = await Promise.race([ + loginCompleted.p, + Event.toPromise(client.onExit).then(event => { throw new Error(`Codex app-server exited during ChatGPT sign-in (code=${event.code}, signal=${event.signal})`); }), + ]); + if (!result.success) { + throw new Error(result.error ?? 'ChatGPT sign-in failed'); + } + await this._refreshAccount(client, true, true); + } + } finally { + completionListener?.dispose(); + } + }); } catch (error) { const message = error instanceof Error ? error.message : String(error); this._setOpenAIAccountState({ usageSource: 'openai', status: 'error', error: message }); @@ -1259,10 +1359,13 @@ export class CodexAgent extends Disposable implements IAgent { private async _signOutOfChatGPT(): Promise { try { - const connection = await this._ensureConnection(); - await connection.client.request<'account/logout'>('account/logout', undefined); - await this._refreshAccount(connection.client); - this._queueModelRefresh(); + await this._withOnDemandConnection(async (client, transient) => { + await client.request<'account/logout'>('account/logout', undefined); + await this._refreshAccount(client, true, transient); + if (!transient) { + this._queueModelRefresh(); + } + }); } catch (error) { const message = error instanceof Error ? error.message : String(error); this._setOpenAIAccountState({ usageSource: 'openai', status: 'error', error: message }); @@ -1280,13 +1383,14 @@ export class CodexAgent extends Disposable implements IAgent { }; } - private _resetSessionForModelProviderChange(session: ICodexSession, modelProvider: string): void { + private async _resetSessionForModelProviderChange(session: ICodexSession, modelProvider: string): Promise { if (session.threadId === undefined) { return; } - this._logService.info(`[Codex:${session.sessionId}] replacing thread ${session.threadId} with a fresh ${modelProvider} thread`); - this._sessionIdByThreadId.delete(session.threadId); - this._mcpInventory.deleteThread(session.threadId); + const oldThreadId = session.threadId; + this._logService.info(`[Codex:${session.sessionId}] replacing thread ${oldThreadId} with a fresh ${modelProvider} thread`); + this._sessionIdByThreadId.delete(oldThreadId); + this._mcpInventory.deleteThread(oldThreadId); session.threadId = undefined; this._applyMcpInventoryToSession(session); session.materializePromise = undefined; @@ -1297,6 +1401,14 @@ export class CodexAgent extends Disposable implements IAgent { session.needsResume = false; session.hostTurnIdByAppTurnId.clear(); session.codexTurnIdByHostTurnId.clear(); + const connection = this._connection; + if (connection.kind === 'ready') { + try { + await connection.client.request<'thread/unsubscribe'>('thread/unsubscribe', { threadId: oldThreadId }); + } catch (error) { + this._logService.info(`[Codex:${oldThreadId}] thread/unsubscribe during model-provider change failed: ${error instanceof Error ? error.message : String(error)}`); + } + } } // #region Auth @@ -1474,11 +1586,17 @@ export class CodexAgent extends Disposable implements IAgent { * applies its own stale-write guards on failure. */ refreshModels(): Promise { + if (this._isShuttingDown || this._store.isDisposed) { + return Promise.resolve(); + } return this._modelsRefreshPromise ?? this._queueModelRefresh(); } private _queueModelRefresh(attempt = 0, generation = this._modelCatalogGeneration): Promise { - const refreshPromise = this._refreshModels(attempt, generation).finally(() => { + if (this._isShuttingDown || this._store.isDisposed) { + return Promise.resolve(); + } + const refreshPromise = this._modelRefreshSequencer.queue(() => this._refreshModels(attempt, generation)).finally(() => { if (this._modelsRefreshPromise === refreshPromise) { this._modelsRefreshPromise = undefined; } @@ -1775,23 +1893,29 @@ export class CodexAgent extends Disposable implements IAgent { } private async _refreshModels(attempt = 0, generation = this._modelCatalogGeneration): Promise { + if (this._isShuttingDown || this._store.isDisposed) { + return; + } // A fresh refresh or the retry itself supersedes the pending timer. this._modelRefreshRetry.clear(); const [copilotError, sdkReady] = await Promise.all([this._refreshCopilotModels(), this._refreshCodexModels()]); + if (generation !== this._modelCatalogGeneration || this._isShuttingDown || this._store.isDisposed) { + return; + } this._models.set([...this._copilotModels, ...this._codexModels], undefined); // Last, never first: also the freshest answer to "is the SDK here" (a // download that landed elsewhere surfaces here), but announcing `ready` // before the catalog lands is how the window renders "no account found". this._sdkSetupChannel.publishWith(sdkReady); - if (!copilotError || generation !== this._modelCatalogGeneration || this._store.isDisposed) { + if (!copilotError) { return; } if (attempt + 1 < this._modelRefreshMaxAttempts) { const delay = modelRefreshBackoff(attempt, this._modelRefreshBaseDelayMs, this._modelRefreshMaxDelayMs); this._logService.warn(`[Codex] Failed to refresh models (attempt ${attempt + 1}), retrying in ${delay}ms: ${copilotError.message}`); this._modelRefreshRetry.value = disposableTimeout(() => { - if (generation === this._modelCatalogGeneration && !this._store.isDisposed) { + if (generation === this._modelCatalogGeneration && !this._isShuttingDown && !this._store.isDisposed) { void this._queueModelRefresh(attempt + 1, generation); } }, delay); @@ -1801,22 +1925,24 @@ export class CodexAgent extends Disposable implements IAgent { } /** - * Ask the app server for the authoritative catalog at startup, but only when - * asking is free — i.e. the SDK is already on disk. + * Re-read the authoritative catalog after an activated Codex provider's root + * configuration changes, but only when asking is free — i.e. the SDK is + * already on disk. * * Replaces a `~/.codex/auth.json` sniff that was wrong in both directions: it * missed API-key setups established through the environment, and claimed a * setup from a stale token file. Only the app server can answer whether this - * user can run Codex without GitHub. Behind the flag, so a Copilot-only user - * still spawns nothing at startup. + * user can run Codex without GitHub. The activation guard is the consent + * boundary: authentication replay and ambient configuration changes alone do + * not start the app-server. */ private _startModelRefreshWhenSdkIsLocal(): void { const allowSignedOutWhenUsable = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.AllowSignedOutWhenUsable) === true; - if (!allowSignedOutWhenUsable || this._codexModels.length > 0) { + if (this._isShuttingDown || this._store.isDisposed || !this._activated || !allowSignedOutWhenUsable || this._codexModels.length > 0) { return; } queueMicrotask(async () => { - if (this._store.isDisposed || !(await this._isSdkResolvableWithoutDownload())) { + if (this._isShuttingDown || this._store.isDisposed || !(await this._isSdkResolvableWithoutDownload()) || this._isShuttingDown || this._store.isDisposed) { return; } await this.refreshModels(); @@ -1893,13 +2019,26 @@ export class CodexAgent extends Disposable implements IAgent { this._codexModels = []; return sdkReady; } + // Account/model enumeration belongs to a selected Codex session. Ambient + // model refreshes may still publish SDK readiness and Copilot models, but + // must not turn the startup account probe into a persistent connection. + if (!this._activated && this._connection.kind === 'idle') { + this._codexModels = []; + return sdkReady; + } const connection = await this._ensureConnection(); const account = await this._refreshAccount(connection.client, false); + if (!this._isCurrentConnection(connection)) { + return sdkReady; + } if (account.status === 'signedOut' || account.status === 'error') { this._codexModels = []; return sdkReady; } const configResponse = await connection.client.request<'config/read', ConfigReadResponse>('config/read', { includeLayers: false }); + if (!this._isCurrentConnection(connection)) { + return sdkReady; + } const modelProvider = configResponse.config.model_provider ?? CODEX_OPENAI_MODEL_PROVIDER; const usesChatGPTSubscription = modelProvider === CODEX_OPENAI_MODEL_PROVIDER && account.status === 'signedIn' && account.authType === 'chatgpt'; const pickerProvider = usesChatGPTSubscription ? 'chatgpt' : modelProvider; @@ -1907,6 +2046,9 @@ export class CodexAgent extends Disposable implements IAgent { let cursor: string | null = null; do { const response: ModelListResponse = await connection.client.request<'model/list', ModelListResponse>('model/list', { cursor, limit: 100, includeHidden: false }); + if (!this._isCurrentConnection(connection)) { + return sdkReady; + } data.push(...response.data); cursor = response.nextCursor; } while (cursor !== null); @@ -1920,7 +2062,9 @@ export class CodexAgent extends Disposable implements IAgent { configSchema: this._createReasoningEffortConfigSchema(model.supportedReasoningEfforts, model.defaultReasoningEffort, model.model), _meta: createAgentModelSourceMeta(usesChatGPTSubscription ? CHATGPT_SUBSCRIPTION_MODEL_SOURCE_ID : undefined), })); - this._codexModels = models; + if (this._isCurrentConnection(connection)) { + this._codexModels = models; + } } catch (err) { this._logService.warn(`[Codex] Failed to refresh OpenAI models: ${err instanceof Error ? err.message : String(err)}`); // Keep the last known-good catalog; a transient periodic failure must @@ -1933,12 +2077,133 @@ export class CodexAgent extends Disposable implements IAgent { // #region Connection lifecycle + private _throwIfShuttingDown(): void { + if (this._isShuttingDown || this._store.isDisposed) { + throw new CancellationError(); + } + } + + /** + * Cross the one process-lifetime activation boundary for Codex. Only session + * creation or restoration may retain a persistent app-server connection. + */ + private _activate(): void { + this._throwIfShuttingDown(); + if (this._activated) { + return; + } + this._activated = true; + this._logService.info('[Codex] Activating app-server after explicit Codex use'); + // Deliberately queue rather than coalesce: an ambient refresh already in + // flight may have observed the inactive state and skipped Codex models. + void this._queueModelRefresh(); + void this._refreshProviderConfiguration(); + if (this._onDidDiscoverChats.hasListeners()) { + void this._startCodexChatDiscovery(); + } + } + + /** + * Run a standalone Codex action on the session-owned connection when Codex is + * active, or on a connection that is always torn down after the action. + * Passive catalogue actions and account controls must not cross the + * persistent activation boundary by themselves. + */ + private async _withOnDemandConnection(operation: (client: ICodexAppServerClient, transient: boolean) => Promise): Promise { + return this._onDemandConnectionSequencer.queue(async () => { + this._throwIfShuttingDown(); + await this._startupAccountProbe.p; + this._throwIfShuttingDown(); + // Recheck after waiting for earlier one-off work: selecting Codex while + // this action was queued moves it onto the retained connection. + if (this._activated || this._connection.kind !== 'idle') { + return operation((await this._ensureConnection()).client, false); + } + const settled = new DeferredPromise(); + this._transientConnectionOperation = settled.p; + const cancellation = new CancellationTokenSource(); + this._transientConnectionCancellation = cancellation; + let connection: IConnectionReady | undefined; + try { + connection = await this._startRawConnection(this._startupAccountProbeTimeoutMs, cancellation.token); + this._transientAccountConnection = connection; + return await operation(connection.client, true); + } finally { + if (connection && this._transientAccountConnection === connection) { + this._transientAccountConnection = undefined; + this._disposeConnectionResources(connection); + } + if (this._transientConnectionCancellation === cancellation) { + this._transientConnectionCancellation = undefined; + } + cancellation.dispose(); + if (this._transientConnectionOperation === settled.p) { + this._transientConnectionOperation = undefined; + } + await settled.complete(undefined); + } + }); + } + + /** + * Resolve the account indicator once at startup without downloading the SDK + * or retaining any app-server resources. + */ + private async _probeAccountAtStartup(): Promise { + let connection: IConnectionReady | undefined; + try { + if (this._isShuttingDown || this._store.isDisposed || !(await this._isSdkResolvableWithoutDownload()) || this._isShuttingDown || this._store.isDisposed) { + return; + } + this._logService.info('[Codex] starting one-off startup account probe'); + const probeConnection = connection = await this._startRawConnection(this._startupAccountProbeTimeoutMs, this._startupAccountProbeCancellation.token); + this._transientAccountConnection = probeConnection; + const account = await raceTimeout((async () => { + const state = await this._refreshAccountState(probeConnection.client, true); + if (state.status === 'signedIn' && state.authType === 'chatgpt' && this._isCurrentChatGPTAccountClient(probeConnection.client, state.email)) { + const profileImageRequest = ++this._openAIAccountProfileImageRequest; + await Promise.all([ + this._refreshAccountRateLimits(probeConnection.client, state.email), + this._readAccountProfileImageAuthentication(probeConnection.client, state.email, profileImageRequest).then(authentication => { + if (authentication) { + // The access token is all the profile request needs from the + // app-server. Let the network/image work continue after the + // one-off native process has been released. + void this._refreshAccountProfileImageFromAuthentication(authentication, state.email, profileImageRequest); + } + }), + ]); + } + return state; + })(), + this._startupAccountProbeTimeoutMs, + () => this._logService.warn(`[Codex] startup account probe timed out after ${this._startupAccountProbeTimeoutMs}ms`), + ); + if (account === undefined) { + return; + } + } catch (error) { + if (!(error instanceof CancellationError)) { + this._logService.warn(`[Codex] startup account probe failed: ${error instanceof Error ? error.message : String(error)}`); + } + } finally { + if (connection) { + if (this._transientAccountConnection === connection) { + this._transientAccountConnection = undefined; + this._disposeConnectionResources(connection); + this._logService.info('[Codex] stopped one-off startup account probe'); + } + } + } + } + /** * Lazily spawn the codex app-server, initialize the connection, * authenticate via apiKey, and return the ready connection. Idempotent * — concurrent callers share the same promise. */ private async _ensureConnection(): Promise { + this._throwIfShuttingDown(); if (this._connection.kind === 'ready') { return Promise.resolve(this._connection); } @@ -1946,25 +2211,46 @@ export class CodexAgent extends Disposable implements IAgent { return this._connection.promise; } const generation = this._connectionGeneration; - const startPromise = this._startConnection(); - const promise = startPromise.then(ready => { + const cancellation = new CancellationTokenSource(); + const startPromise = (async () => { + await this._startupAccountProbe.p; + this._throwIfShuttingDown(); + if (cancellation.token.isCancellationRequested) { + throw new CancellationError(); + } + const transientOperation = this._transientConnectionOperation; + if (transientOperation) { + await transientOperation; + } + this._throwIfShuttingDown(); + return this._startConnection(generation, cancellation.token); + })(); + const promise = startPromise.then(async ready => { if (generation !== this._connectionGeneration) { - ready.client.dispose(); - ready.proxyHandle.dispose(); - try { ready.child.kill('SIGKILL'); } catch { /* already dead */ } - throw new Error('Codex app-server was replaced while starting'); + this._disposeConnectionResources(ready); + throw new CodexConnectionReplacedError('Codex app-server was replaced while starting'); } // Authentication can complete while the connection is starting; apply the latest token before publishing ready. ready.proxyHandle.setToken(this._githubToken ?? ''); + // Skill roots are process-global app-server state. Seed every new process + // before exposing it to thread/start or thread/resume, including a + // replacement process after an unexpected disconnect. + await this._queueSkillExtraRootsForClient(ready.client); + if (generation !== this._connectionGeneration) { + this._disposeConnectionResources(ready); + throw new CodexConnectionReplacedError('Codex app-server was replaced while starting'); + } this._connection = { kind: 'ready', ...ready }; + void this._refreshAccount(ready.client); + void this._refreshMcpInventory(ready.client, null); return ready; }).catch(err => { if (generation === this._connectionGeneration) { this._connection = { kind: 'idle' }; } throw err; - }); - this._connection = { kind: 'starting', promise }; + }).finally(() => cancellation.dispose()); + this._connection = { kind: 'starting', promise, cancellation }; return promise; } @@ -1985,16 +2271,19 @@ export class CodexAgent extends Disposable implements IAgent { * resolves we defer to the downloader so callers get its actionable * "not configured" diagnostic. */ - private async _resolveSdkRoot(): Promise { + private async _resolveSdkRoot(token: CancellationToken = CancellationToken.None): Promise { if (this._agentSdkDownloader.isAvailable(CodexSdkPackage)) { - return this._agentSdkDownloader.loadSdkRoot(CodexSdkPackage, CancellationToken.None); + return this._agentSdkDownloader.loadSdkRoot(CodexSdkPackage, token); } const devRoot = await resolveCodexDevSdkRoot(); + if (token.isCancellationRequested) { + throw new CancellationError(); + } if (devRoot) { this._logService.info(`[Codex] resolving SDK from repo node_modules (dev fallback): ${devRoot}`); return devRoot; } - return this._agentSdkDownloader.loadSdkRoot(CodexSdkPackage, CancellationToken.None); + return this._agentSdkDownloader.loadSdkRoot(CodexSdkPackage, token); } private async _isSdkResolvableWithoutDownload(): Promise { @@ -2004,7 +2293,8 @@ export class CodexAgent extends Disposable implements IAgent { return (await resolveCodexDevSdkRoot()) !== undefined; } - private async _startConnection(): Promise { + /** Spawn and initialize an app-server without retaining it as the agent's connection. */ + private async _startRawConnection(initializationTimeoutMs?: number, token: CancellationToken = CancellationToken.None): Promise { // Resolve the Codex SDK root: dev override / product download via the // downloader, or this repo's `node_modules` in a source checkout (see // `_resolveSdkRoot`). We spawn the native codex binary inside the @@ -2013,7 +2303,7 @@ export class CodexAgent extends Disposable implements IAgent { // through the shim adds a launcher hop and forces an // `ELECTRON_RUN_AS_NODE` round-trip when the agent host runs as an // Electron utility process. - const root = await this._resolveSdkRoot(); + const root = await this._resolveSdkRoot(token); const codexTarget = codexPackageSuffix(process.platform, process.arch); if (!codexTarget) { throw new Error(`Codex: unsupported platform ${process.platform}-${process.arch}`); @@ -2030,102 +2320,143 @@ export class CodexAgent extends Disposable implements IAgent { throw new Error(`Codex binary not executable: ${binaryPath} (${err instanceof Error ? err.message : String(err)})`); } - const proxyHandle = await this._codexProxyService.start(this._githubToken ?? ''); - - const extraArgs = parseBinaryArgs(process.env[AgentHostCodexAgentBinaryArgsEnvVar]); - const telemetry = await this._otelService.getNativeSdkTelemetryConfig(); - const launchConfig = buildCodexLaunchConfig(process.env, proxyHandle, extraArgs, telemetry); - const env = launchConfig.env; - const userCodexHome = process.env[AgentHostCodexAgentCodexHomeEnvVar]; - if (userCodexHome) { - env.CODEX_HOME = userCodexHome; - } - - const args = [...launchConfig.args]; - - this._logService.info(`[Codex] spawning with additive model providers ${binaryPath} ${args.join(' ')}`); - const child = spawn(binaryPath, args, { env, stdio: ['pipe', 'pipe', 'pipe'] }); - - // Surface stderr to the log channel — codex writes useful startup - // diagnostics there. Mirror Claude's pattern. - child.stderr.setEncoding('utf8'); - child.stderr.on('data', chunk => this._logService.info(`[Codex stderr] ${String(chunk).trimEnd()}`)); - - const transport = transportFromChildProcess(child); - const client = new CodexAppServerClient(transport, (level, msg) => { - this._logService.info(`[CodexClient ${level}] ${msg}`); - }); - - // Tear everything down if the child dies on its own. - client.onExit(e => { - this._logService.warn(`[Codex] app-server exited code=${e.code} signal=${e.signal}`); - this._handleConnectionLost(); - }); - client.onTransportError(err => { - this._logService.error(`[Codex] transport error: ${err.message}`); - this._handleConnectionLost(); - }); - - // Initialize handshake. Failure here is fatal for the connection. + const proxyStart = this._codexProxyService.start(this._githubToken ?? ''); + let proxyHandle: ICodexProxyHandle; try { - await client.request<'initialize'>('initialize', { + proxyHandle = await raceCancellationError(proxyStart, token); + } catch (error) { + // The proxy API has no cancellation input. If its start finishes after + // this connection was cancelled, release that late handle immediately. + void proxyStart.then(handle => handle.dispose(), () => { }); + throw error; + } + let child: ChildProcessWithoutNullStreams | undefined; + let client: CodexAppServerClient | undefined; + try { + if (token.isCancellationRequested) { + throw new CancellationError(); + } + const extraArgs = parseBinaryArgs(process.env[AgentHostCodexAgentBinaryArgsEnvVar]); + const telemetry = await this._otelService.getNativeSdkTelemetryConfig(); + const launchConfig = buildCodexLaunchConfig(process.env, proxyHandle, extraArgs, telemetry); + const env = launchConfig.env; + const userCodexHome = process.env[AgentHostCodexAgentCodexHomeEnvVar]; + if (userCodexHome) { + env.CODEX_HOME = userCodexHome; + } + + const args = [...launchConfig.args]; + // Launch overrides can contain user-supplied arguments and telemetry + // exporter headers. Keep them out of the persistent agent-host log. + this._logService.info(`[Codex] spawning app-server from ${binaryPath}`); + child = spawn(binaryPath, args, { env, stdio: ['pipe', 'pipe', 'pipe'] }); + + // Surface stderr to the log channel — codex writes useful startup + // diagnostics there. Mirror Claude's pattern. + child.stderr.setEncoding('utf8'); + child.stderr.on('data', chunk => this._logService.info(`[Codex stderr] ${String(chunk).trimEnd()}`)); + + const transport = transportFromChildProcess(child); + client = new CodexAppServerClient(transport, (level, msg) => { + this._logService.info(`[CodexClient ${level}] ${msg}`); + }); + + // Initialize handshake. Failure here is fatal for this connection. + const initialize = raceCancellationError(client.request<'initialize'>('initialize', { clientInfo: CLIENT_INFO, capabilities: { experimentalApi: true, requestAttestation: false, optOutNotificationMethods: null }, - }); + }), token); + if (initializationTimeoutMs === undefined) { + await initialize; + } else if (await raceTimeout(initialize, initializationTimeoutMs) === undefined) { + throw new Error(`Codex app-server initialization timed out after ${initializationTimeoutMs}ms`); + } + if (token.isCancellationRequested) { + throw new CancellationError(); + } client.notify<'initialized'>('initialized', undefined as never); - void this._refreshAccount(client); + return { client, proxyHandle, child }; } catch (err) { - client.dispose(); + client?.dispose(); proxyHandle.dispose(); - try { child.kill('SIGKILL'); } catch { /* already dead */ } + try { child?.kill('SIGKILL'); } catch { /* already dead */ } throw err; } + } + + /** Start and retain the fully-wired connection used by Codex sessions. */ + private async _startConnection(generation: number, token: CancellationToken): Promise { + const raw = await this._startRawConnection(undefined, token); + const subscriptions = new DisposableStore(); + const ready: IConnectionReady = { ...raw, subscriptions }; + const { client } = ready; + + // Tear everything down if the persistent child dies on its own. + subscriptions.add(client.onExit(e => { + this._logService.warn(`[Codex] app-server exited code=${e.code} signal=${e.signal}`); + this._handleConnectionLost(ready, generation); + })); + subscriptions.add(client.onTransportError(err => { + this._logService.error(`[Codex] transport error: ${err.message}`); + this._handleConnectionLost(ready, generation); + })); + // The raw initialize response can win a race with the child exiting. An + // exit that happened before the listeners above were attached is not + // replayed by Node's EventEmitter, so inspect the child after subscribing + // and reject instead of publishing a permanently dead ready connection. + const exitCode = raw.child.exitCode; + const signalCode = raw.child.signalCode; + if ((exitCode !== null && exitCode !== undefined) || (signalCode !== null && signalCode !== undefined)) { + this._disposeConnectionResources(ready); + throw new Error(`Codex app-server exited before persistent startup completed (code=${exitCode ?? 'null'}, signal=${signalCode ?? 'null'})`); + } // Wire global notification → SessionAction dispatch. - this._registerIgnoredNotifications(client); - this._register(client.onNotification('account/login/completed', () => { + this._registerIgnoredNotifications(client, subscriptions); + subscriptions.add(client.onNotification('account/login/completed', () => { void this._refreshAccount(client).then(() => this._queueModelRefresh()); })); - this._register(client.onNotification('account/updated', () => { + subscriptions.add(client.onNotification('account/updated', () => { if (this._connection.kind === 'ready' && this._connection.client === client) { void this._refreshAccount(client); this._queueModelRefresh(); } })); - this._register(client.onNotification('account/rateLimits/updated', () => { + subscriptions.add(client.onNotification('account/rateLimits/updated', () => { if (this._connection.kind === 'ready' && this._connection.client === client && this._openAIAccountState.status === 'signedIn' && this._openAIAccountState.authType === 'chatgpt') { void this._refreshAccountRateLimits(client); } })); - this._register(client.onNotification('turn/started', params => this._dispatchByThread(params.threadId, s => this._handleTurnStartedNotification(s, params)))); - this._register(client.onNotification('item/started', params => this._dispatchByThread(params.threadId, s => this._handleItemStarted(s, params)))); - this._register(client.onNotification('item/agentMessage/delta', params => this._dispatchByThread(params.threadId, s => mapAgentMessageDelta(s.mapState, this._withHostTurnId(s, params))))); - this._register(client.onNotification('item/commandExecution/outputDelta', params => this._dispatchByThread(params.threadId, s => mapCommandExecutionOutputDelta(s.mapState, this._withHostTurnId(s, params))))); - this._register(client.onNotification('item/fileChange/patchUpdated', params => this._dispatchByThread(params.threadId, s => mapFileChangePatchUpdated(s.mapState, this._withHostTurnId(s, params))))); - this._register(client.onNotification('item/fileChange/outputDelta', params => this._dispatchByThread(params.threadId, s => mapFileChangeOutputDelta(s.mapState, this._withHostTurnId(s, params))))); - this._register(client.onNotification('item/mcpToolCall/progress', params => this._dispatchByThread(params.threadId, s => mapMcpToolCallProgress(s.mapState, this._withHostTurnId(s, params))))); - this._register(client.onNotification('item/reasoning/summaryPartAdded', params => this._dispatchByThread(params.threadId, s => mapReasoningSummaryPartAdded(s.mapState, this._withHostTurnId(s, params))))); - this._register(client.onNotification('item/reasoning/summaryTextDelta', params => this._dispatchByThread(params.threadId, s => mapReasoningSummaryTextDelta(s.mapState, this._withHostTurnId(s, params))))); - this._register(client.onNotification('item/reasoning/textDelta', params => this._dispatchByThread(params.threadId, s => mapReasoningTextDelta(s.mapState, this._withHostTurnId(s, params))))); - this._register(client.onNotification('thread/tokenUsage/updated', params => this._dispatchTokenUsageUpdated(params))); - this._register(client.onNotification('item/completed', params => this._dispatchItemCompleted(params))); - this._register(client.onNotification('turn/completed', params => this._dispatchTurnCompleted(params))); + subscriptions.add(client.onNotification('skills/changed', () => this._queueSkillHookCustomizationRefresh(client))); + subscriptions.add(client.onNotification('turn/started', params => this._dispatchByThread(params.threadId, s => this._handleTurnStartedNotification(s, params)))); + subscriptions.add(client.onNotification('item/started', params => this._dispatchByThread(params.threadId, s => this._handleItemStarted(s, params)))); + subscriptions.add(client.onNotification('item/agentMessage/delta', params => this._dispatchByThread(params.threadId, s => mapAgentMessageDelta(s.mapState, this._withHostTurnId(s, params))))); + subscriptions.add(client.onNotification('item/commandExecution/outputDelta', params => this._dispatchByThread(params.threadId, s => mapCommandExecutionOutputDelta(s.mapState, this._withHostTurnId(s, params))))); + subscriptions.add(client.onNotification('item/fileChange/patchUpdated', params => this._dispatchByThread(params.threadId, s => mapFileChangePatchUpdated(s.mapState, this._withHostTurnId(s, params))))); + subscriptions.add(client.onNotification('item/fileChange/outputDelta', params => this._dispatchByThread(params.threadId, s => mapFileChangeOutputDelta(s.mapState, this._withHostTurnId(s, params))))); + subscriptions.add(client.onNotification('item/mcpToolCall/progress', params => this._dispatchByThread(params.threadId, s => mapMcpToolCallProgress(s.mapState, this._withHostTurnId(s, params))))); + subscriptions.add(client.onNotification('item/reasoning/summaryPartAdded', params => this._dispatchByThread(params.threadId, s => mapReasoningSummaryPartAdded(s.mapState, this._withHostTurnId(s, params))))); + subscriptions.add(client.onNotification('item/reasoning/summaryTextDelta', params => this._dispatchByThread(params.threadId, s => mapReasoningSummaryTextDelta(s.mapState, this._withHostTurnId(s, params))))); + subscriptions.add(client.onNotification('item/reasoning/textDelta', params => this._dispatchByThread(params.threadId, s => mapReasoningTextDelta(s.mapState, this._withHostTurnId(s, params))))); + subscriptions.add(client.onNotification('thread/tokenUsage/updated', params => this._dispatchTokenUsageUpdated(params))); + subscriptions.add(client.onNotification('item/completed', params => this._dispatchItemCompleted(params))); + subscriptions.add(client.onNotification('turn/completed', params => this._dispatchTurnCompleted(params))); // Auto-review (guardian) surfacing. The guardian warning is shown as a // system notification; a completed *denied* review is turned into a // retroactive "Approve anyway" tool-call card. The review lifecycle is // non-blocking (codex does not wait on us), so the completed handler is // async and resolves its session directly rather than via _dispatchByThread. - this._register(client.onNotification('guardianWarning', params => this._dispatchByThread(params.threadId, s => this._handleGuardianWarning(s, params)))); - this._register(client.onNotification('item/autoApprovalReview/completed', params => { void this._handleGuardianReviewCompleted(client, params); })); + subscriptions.add(client.onNotification('guardianWarning', params => this._dispatchByThread(params.threadId, s => this._handleGuardianWarning(s, params)))); + subscriptions.add(client.onNotification('item/autoApprovalReview/completed', params => { void this._handleGuardianReviewCompleted(client, params); })); // The notification's thread id scopes per-session MCP configurations. - this._register(client.onNotification('mcpServer/startupStatus/updated', params => this._handleMcpStartupStatus(client, params.threadId, params.name, params.status, params.error))); + subscriptions.add(client.onNotification('mcpServer/startupStatus/updated', params => this._handleMcpStartupStatus(client, params.threadId, params.name, params.status, params.error))); // Phase 4: command-execution approval requests. Park on a // per-session deferred, emit `ChatToolCallReady` in the // PendingConfirmation state, and answer codex when the user // (or accept-for-session memoization) decides. - this._register(client.onRequest<'item/commandExecution/requestApproval'>( + subscriptions.add(client.onRequest<'item/commandExecution/requestApproval'>( 'item/commandExecution/requestApproval', params => this._handleCommandApprovalRequestRpc(params), )); @@ -2133,11 +2464,11 @@ export class CodexAgent extends Disposable implements IAgent { // File-change and permission-escalation approval requests (raised in // non-`danger-full-access` sandboxes / on the on-request approval // policy). Surface them through the same pending-confirmation flow. - this._register(client.onRequest<'item/fileChange/requestApproval'>( + subscriptions.add(client.onRequest<'item/fileChange/requestApproval'>( 'item/fileChange/requestApproval', params => this._handleFileChangeApprovalRequestRpc(params), )); - this._register(client.onRequest<'item/permissions/requestApproval'>( + subscriptions.add(client.onRequest<'item/permissions/requestApproval'>( 'item/permissions/requestApproval', params => this._handlePermissionsApprovalRequestRpc(params), )); @@ -2146,14 +2477,14 @@ export class CodexAgent extends Disposable implements IAgent { // host to run a tool registered via `thread/start.dynamicTools`; we // route the call to the owning workbench client and answer with its // result. - this._register(client.onRequest<'item/tool/call'>( + subscriptions.add(client.onRequest<'item/tool/call'>( 'item/tool/call', params => this._handleDynamicToolCallRpc(params), )); // User-input requests (the model's `ask_user`). Surface the questions // as a chat input request and answer codex with the user's response. - this._register(client.onRequest<'item/tool/requestUserInput'>( + subscriptions.add(client.onRequest<'item/tool/requestUserInput'>( 'item/tool/requestUserInput', params => this._handleUserInputRequestRpc(params), )); @@ -2161,17 +2492,12 @@ export class CodexAgent extends Disposable implements IAgent { // MCP elicitation requests. An MCP server (relayed by codex) asks the // user for structured input mid-tool-call. Surface it through the same // chat-input flow as `ask_user` and answer codex with accept/decline/cancel. - this._register(client.onRequest<'mcpServer/elicitation/request'>( + subscriptions.add(client.onRequest<'mcpServer/elicitation/request'>( 'mcpServer/elicitation/request', params => this._handleElicitationRequestRpc(params), )); - // Seed the MCP server inventory from the freshly-connected app-server. - // Best-effort and fire-and-forget: failures leave the inventory empty - // until the next `mcpServer/startupStatus/updated` notification. - void this._refreshMcpInventory(client, null); - - return { client, proxyHandle, child }; + return ready; } /** @@ -2663,7 +2989,7 @@ export class CodexAgent extends Disposable implements IAgent { this._onDidChatProgress.fire({ kind: 'steering_consumed', chat: session.chatChannel!, id }); } - private _registerIgnoredNotifications(client: ICodexAppServerClient): void { + private _registerIgnoredNotifications(client: ICodexAppServerClient, subscriptions: DisposableStore): void { const ignored = [ 'thread/started', // thread/start response is authoritative for session materialization. 'thread/status/changed', // Codex thread status is not surfaced in Agent Host state yet. @@ -2676,38 +3002,78 @@ export class CodexAgent extends Disposable implements IAgent { 'item/autoApprovalReview/started', // Informational; the completed notification drives the denied-action card. ] as const; for (const method of ignored) { - this._register(client.onNotification(method, () => { /* intentionally ignored */ })); + subscriptions.add(client.onNotification(method, () => { /* intentionally ignored */ })); } } - private async _refreshAccount(client: ICodexAppServerClient, publish = true): Promise { + private async _refreshAccount(client: ICodexAppServerClient, publish = true, awaitDetails = false): Promise { + const state = await this._refreshAccountState(client, publish); + if (publish && state.status === 'signedIn' && state.authType === 'chatgpt' && this._isCurrentChatGPTAccountClient(client, state.email)) { + const details = Promise.all([ + this._refreshAccountRateLimits(client, state.email), + this._refreshAccountProfileImage(client, state.email), + ]); + if (awaitDetails) { + await details; + } else { + void details; + } + } + return state; + } + + private _refreshAccountState(client: ICodexAppServerClient, publish: boolean): Promise { + return this._accountRefreshSequencer.queue(() => this._doRefreshAccount(client, publish)); + } + + private async _doRefreshAccount(client: ICodexAppServerClient, publish: boolean): Promise { try { const response = await client.request<'account/read', GetAccountResponse>('account/read', { refreshToken: false }); const state = codexAccountStateFromResponse(response); - this._setOpenAIAccountState(state, publish); - if (publish && state.status === 'signedIn' && state.authType === 'chatgpt') { - void this._refreshAccountRateLimits(client, state.email); - void this._refreshAccountProfileImage(client, state.email); + if (!this._isActiveAccountClient(client)) { + return state; } + this._setOpenAIAccountState(state, publish); this._logService.info(`[Codex] account/read accountType=${response.account?.type ?? 'none'} requiresOpenaiAuth=${response.requiresOpenaiAuth}${state.planType ? ` planType=${state.planType}` : ''}`); return state; } catch (err) { const message = err instanceof Error ? err.message : String(err); this._logService.warn(`[Codex] account/read failed: ${message}`); const state: ICodexAccountState = { usageSource: 'openai', status: 'error', error: message }; - this._setOpenAIAccountState(state, publish); + if (this._isActiveAccountClient(client)) { + this._setOpenAIAccountState(state, publish); + } return state; } } private async _refreshAccountProfileImage(client: ICodexAppServerClient, accountEmail = this._openAIAccountState.email): Promise { const request = ++this._openAIAccountProfileImageRequest; + const authentication = await this._readAccountProfileImageAuthentication(client, accountEmail, request); + if (authentication) { + await this._refreshAccountProfileImageFromAuthentication(authentication, accountEmail, request); + } + } + + private async _readAccountProfileImageAuthentication(client: ICodexAppServerClient, accountEmail: string | undefined, request: number): Promise<{ readonly authToken: string | null } | undefined> { try { const response = await client.request<'getAuthStatus', GetAuthStatusResponse>('getAuthStatus', { includeToken: true, refreshToken: false }); - const profileImage = response.authToken - ? await fetchCodexProfileImage(response.authToken, (input, init) => this._proxyResolver.fetch(input, init)) + if (request !== this._openAIAccountProfileImageRequest || !this._isCurrentChatGPTAccountClient(client, accountEmail)) { + return undefined; + } + return { authToken: response.authToken }; + } catch (error) { + this._logService.warn(`[Codex] ChatGPT profile image authentication refresh failed: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } + } + + private async _refreshAccountProfileImageFromAuthentication(authentication: { readonly authToken: string | null }, accountEmail: string | undefined, request: number): Promise { + try { + const profileImage = authentication.authToken + ? await fetchCodexProfileImage(authentication.authToken, (input, init) => this._proxyResolver.fetch(input, init)) : undefined; - if (request !== this._openAIAccountProfileImageRequest || this._connection.kind !== 'ready' || this._connection.client !== client || this._openAIAccountState.status !== 'signedIn' || this._openAIAccountState.authType !== 'chatgpt' || this._openAIAccountState.email !== accountEmail) { + if (request !== this._openAIAccountProfileImageRequest || !this._isCurrentChatGPTAccount(accountEmail)) { return; } const profileImageReference = profileImage @@ -2716,7 +3082,7 @@ export class CodexAgent extends Disposable implements IAgent { if (!profileImage) { await this._profileImageStore?.clear(); } - if (request !== this._openAIAccountProfileImageRequest || this._connection.kind !== 'ready' || this._connection.client !== client || this._openAIAccountState.status !== 'signedIn' || this._openAIAccountState.authType !== 'chatgpt' || this._openAIAccountState.email !== accountEmail) { + if (request !== this._openAIAccountProfileImageRequest || !this._isCurrentChatGPTAccount(accountEmail)) { return; } if (profileImageReference?.nonce === this._openAIAccountProfileImage?.nonce) { @@ -2734,9 +3100,10 @@ export class CodexAgent extends Disposable implements IAgent { } private async _refreshAccountRateLimits(client: ICodexAppServerClient, accountEmail = this._openAIAccountState.email): Promise { + const request = ++this._openAIAccountRateLimitRequest; try { const response = await client.request<'account/rateLimits/read', GetAccountRateLimitsResponse>('account/rateLimits/read', undefined); - if (this._connection.kind !== 'ready' || this._connection.client !== client || this._openAIAccountState.status !== 'signedIn' || this._openAIAccountState.authType !== 'chatgpt' || this._openAIAccountState.email !== accountEmail) { + if (request !== this._openAIAccountRateLimitRequest || !this._isCurrentChatGPTAccountClient(client, accountEmail)) { return; } this._openAIAccountRateLimit = codexAccountRateLimitFromResponse(response); @@ -2746,14 +3113,34 @@ export class CodexAgent extends Disposable implements IAgent { } } - private async _readProviderConfiguration(): Promise> { + private _isCurrentChatGPTAccountClient(client: ICodexAppServerClient, accountEmail: string | undefined): boolean { + return this._isActiveAccountClient(client) + && this._isCurrentChatGPTAccount(accountEmail); + } + + private _isCurrentChatGPTAccount(accountEmail: string | undefined): boolean { + return !this._isShuttingDown + && !this._store.isDisposed + && this._openAIAccountState.status === 'signedIn' + && this._openAIAccountState.authType === 'chatgpt' + && this._openAIAccountState.email === accountEmail; + } + + private _isActiveAccountClient(client: ICodexAppServerClient): boolean { + return (this._connection.kind === 'ready' && this._connection.client === client) + || this._transientAccountConnection?.client === client; + } + + private async _readProviderConfiguration(): Promise<{ readonly connection: IConnectionReady; readonly values: Record }> { const connection = await this._ensureConnection(); const response = await connection.client.request<'config/read', ConfigReadResponse>('config/read', { includeLayers: true }); const userLayer = response.layers?.find(layer => layer.name.type === 'user' && layer.name.profile === null) ?? response.layers?.find(layer => layer.name.type === 'user'); const config = userLayer?.config && typeof userLayer.config === 'object' && !Array.isArray(userLayer.config) ? userLayer.config as Record : {}; return { - 'codex.personality': this._readConfigurationValue(config, 'personality') ?? 'default', - 'codex.autoReviewPolicy': this._readConfigurationValue(config, 'auto_review.policy') ?? '', + connection, values: { + 'codex.personality': this._readConfigurationValue(config, 'personality') ?? 'default', + 'codex.autoReviewPolicy': this._readConfigurationValue(config, 'auto_review.policy') ?? '', + } }; } @@ -2771,12 +3158,19 @@ export class CodexAgent extends Disposable implements IAgent { } private _refreshProviderConfiguration(): Promise { + if (!this._activated) { + return Promise.resolve(); + } return this._providerConfigurationRefresh ??= (async () => { try { if (this._connection.kind === 'idle' && !(await this._isSdkResolvableWithoutDownload())) { return; } - this._providerConfigurationValues = await this._readProviderConfiguration(); + const result = await this._readProviderConfiguration(); + if (!this._isCurrentConnection(result.connection)) { + return; + } + this._providerConfigurationValues = result.values; this._providerConfigurationReady = true; this._configurationService.updateRootConfig(this._providerConfigurationValues); } catch (error) { @@ -3041,7 +3435,8 @@ export class CodexAgent extends Disposable implements IAgent { materializedEventFired: true, prewarmTimer: undefined, prewarmClaimed: true, - serverToolsAdvertised: true, + serverToolsAdvertisement: parent.serverToolsAdvertisement, + publishedDirectoryCustomizationIds: new Set(), mcpController: undefined, clientCustomizations: new CodexClientCustomizationStore(), }; @@ -3363,15 +3758,37 @@ export class CodexAgent extends Disposable implements IAgent { } } - private _handleConnectionLost(): void { - const conn = this._connection; - if (conn.kind !== 'ready') { + private _handleConnectionLost(connection: IConnectionReady, generation: number): void { + if (generation !== this._connectionGeneration) { return; } + const state = this._connection; + if (state.kind === 'idle' || (state.kind === 'ready' && state.client !== connection.client)) { + return; + } + // Invalidate the pending publication of a connection that died between + // initialization and `_ensureConnection` promoting it to `ready`. + this._connectionGeneration++; + this._modelCatalogGeneration++; this._connection = { kind: 'idle' }; + this._skillHookCustomizationRefresh.clear(); + this._pendingMcpStartupStatuses.clear(); + this._mcpInventory.clear(); + this._applyGlobalMcpInventoryToSessions(); + if (state.kind === 'starting') { + this._disposeConnectionResources(connection); + return; + } // Notify every known session with a single ChatError + complete // pair so the UI surfaces "agent disconnected" cleanly. for (const session of this._sessions.values()) { + // A replacement app-server has no in-memory copy of any thread that + // was materialized on this connection. The next operation must resume + // it before issuing a turn or another thread-scoped request. + if (session.threadId !== undefined) { + session.needsResume = true; + session.unsubscribeBeforeResume = false; + } // Unpark any pending approvals so awaiters unwind. session.pendingCommandApprovals.denyAll('decline'); // Reject in-flight client tool calls so their handlers unwind. @@ -3407,16 +3824,7 @@ export class CodexAgent extends Disposable implements IAgent { this._subagentsByThreadId.clear(); // Release resources. The proxy handle is refcounted and drops // the underlying server once everyone releases. - try { - conn.client.dispose(); - } catch (err) { - this._logService.error(`[Codex] Failed to dispose app-server client after connection lost: ${err instanceof Error ? err.message : String(err)}`); - } - try { - conn.proxyHandle?.dispose(); - } catch (err) { - this._logService.error(`[Codex] Failed to dispose proxy handle after connection lost: ${err instanceof Error ? err.message : String(err)}`); - } + this._disposeConnectionResources(connection); } private _disposeConnection(): void { @@ -3424,11 +3832,34 @@ export class CodexAgent extends Disposable implements IAgent { this._connectionGeneration++; this._connection = { kind: 'idle' }; this._pendingMcpStartupStatuses.clear(); - if (connection.kind !== 'ready') { + if (connection.kind === 'starting') { + connection.cancellation.dispose(true); return; } + if (connection.kind === 'idle') { + return; + } + this._disposeConnectionResources(connection); + } + + private _disposeTransientAccountConnection(): void { + this._transientConnectionCancellation?.dispose(true); + this._transientConnectionCancellation = undefined; + const connection = this._transientAccountConnection; + this._transientAccountConnection = undefined; + if (connection) { + this._disposeConnectionResources(connection); + } + } + + private _disposeConnectionResources(connection: IConnectionReady): void { + if (this._disposedConnections.has(connection)) { + return; + } + this._disposedConnections.add(connection); + try { connection.subscriptions?.dispose(); } catch { /* ignore */ } try { connection.client.dispose(); } catch { /* ignore */ } - try { connection.proxyHandle?.dispose(); } catch { /* ignore */ } + try { connection.proxyHandle.dispose(); } catch { /* ignore */ } try { connection.child.kill('SIGKILL'); } catch { /* already dead */ } } @@ -3468,21 +3899,20 @@ export class CodexAgent extends Disposable implements IAgent { /** * Resolve a host-addressed Codex chat to the session of the runtime backing - * it. Resolution has exactly two sources, in order: the binding this agent - * recorded when the chat was provisioned or restored, and the transient - * `{ configurationResource, resource }` context Agent Host supplies for - * operations that run before a binding exists. There is deliberately no - * third fallback — neither chat-URI shape parsing, nor host-side - * membership heuristics, nor the legacy "a session URI addresses its own - * chat" adapter — so an unaddressable chat surfaces as `undefined` instead - * of silently routing to some other conversation. + * it. The binding this agent recorded when the chat was provisioned or + * restored is the only source of runtime identity. In particular, + * `context.configurationResource` names the chat's configuration scope, not + * its backing thread: using it as a fallback for an unbound peer can route a + * dispose, model change, history read, or turn to the owning session's + * different conversation. An unaddressable chat therefore surfaces as + * `undefined` instead of silently routing to some other conversation. */ - private _resolveConversationSession(address: URI, sessionOrContext?: URI | IAgentChatContext): URI | undefined { + private _resolveConversationSession(address: URI, _sessionOrContext?: URI | IAgentChatContext): URI | undefined { const sessionId = this._sessionIdByChatUri.get(address.toString()); if (sessionId) { return AgentSession.uri(this.id, sessionId); } - return sessionOrContext ? resolveAgentChatContext(sessionOrContext, address).configurationResource : undefined; + return undefined; } /** @@ -3508,16 +3938,43 @@ export class CodexAgent extends Disposable implements IAgent { return this._resolveConversationSession(chat) ?? chat; } - /** Registers `chat` as live under `configurationResource`'s ref-tracked scope. Idempotent. */ - private _trackConfigScopeChat(configurationResource: URI, chat: URI): void { + /** + * Registers `chat` as live under `configurationResource`'s ref-tracked + * scope. When an already-bound chat moves between scopes, remove its old + * membership before publishing the new inverse entry. Returns the old + * scope when that move emptied it so its resources can be reclaimed. + */ + private _trackConfigScopeChat(configurationResource: URI, chat: URI): URI | undefined { const key = configurationResource.toString(); + const chatKey = chat.toString(); + const previousKey = this._configScopeByChat.get(chatKey); + if (previousKey === key) { + return undefined; + } + let emptiedPreviousScope: URI | undefined; + if (previousKey !== undefined) { + const previousChats = this._configScopeChats.get(previousKey); + previousChats?.delete(chatKey); + if (previousChats?.size === 0) { + this._configScopeChats.delete(previousKey); + emptiedPreviousScope = URI.parse(previousKey); + } + } let chats = this._configScopeChats.get(key); if (!chats) { chats = new Set(); this._configScopeChats.set(key, chats); } - chats.add(chat.toString()); - this._configScopeByChat.set(chat.toString(), key); + chats.add(chatKey); + this._configScopeByChat.set(chatKey, key); + return emptiedPreviousScope; + } + + private async _moveConfigScopeChat(configurationResource: URI, chat: URI): Promise { + const emptiedPreviousScope = this._trackConfigScopeChat(configurationResource, chat); + if (emptiedPreviousScope) { + await this._reclaimManagedWorkingDirectoryIfNotLive(emptiedPreviousScope); + } } /** @@ -3557,7 +4014,7 @@ export class CodexAgent extends Disposable implements IAgent { */ private async _reclaimManagedWorkingDirectoryIfNotLive(sessionUri: URI): Promise { const sessionId = AgentSession.id(sessionUri); - if (this._sessions.has(sessionId)) { + if (this._hasSessionBacking(sessionId)) { return; } this._otelService.releaseSessionTraceContext(sessionUri.toString()); @@ -3625,10 +4082,10 @@ export class CodexAgent extends Disposable implements IAgent { */ readonly chats: IAgentChats = { createChat: (chat: URI, context: URI | IAgentChatContext, options?: IAgentCreateChatOptions): Promise => { - return this._createChat(chat, resolveAgentChatContext(context, chat), options); + return this._chatLifecycleSequencer.queue(chat.toString(), () => this._createChat(chat, resolveAgentChatContext(context, chat), options)); }, - disposeChat: (chat: URI, context: URI | IAgentChatContext): Promise => this._disposeChat(chat, context), - releaseChat: (chat: URI, context: URI | IAgentChatContext): Promise => this._releaseChat(chat, context), + disposeChat: (chat: URI, context: URI | IAgentChatContext): Promise => this._chatLifecycleSequencer.queue(chat.toString(), () => this._disposeChat(chat, context)), + releaseChat: (chat: URI, context: URI | IAgentChatContext): Promise => this._chatLifecycleSequencer.queue(chat.toString(), () => this._releaseChat(chat, context)), sendMessage: (chat: URI, prompt: string, workingDirectoriesOrDirectory: readonly URI[] | URI | undefined, attachments?: readonly MessageAttachment[], turnId?: string, _senderClientId?: string, clientTypeOrContext?: AgentHostClientType | URI | IAgentChatContext, context?: URI | IAgentChatContext): Promise => { const workingDirectories = Array.isArray(workingDirectoriesOrDirectory) ? workingDirectoriesOrDirectory : workingDirectoriesOrDirectory ? [workingDirectoriesOrDirectory] : undefined; const operationContext = context ?? (typeof clientTypeOrContext === 'string' ? undefined : clientTypeOrContext); @@ -3696,14 +4153,9 @@ export class CodexAgent extends Disposable implements IAgent { * half-registered chat piling onto the next attempt. */ private async _createChat(chat: URI, context: IAgentChatContext, options?: IAgentCreateChatOptions): Promise { + this._activate(); const target: ICodexTargetChat = { resource: chat, configurationResource: context.configurationResource }; const owningSessionId = AgentSession.id(context.configurationResource); - this._logService.info(`[Codex DEBUG] createChat accountStatus=${this._openAIAccountState.status} session=${context.configurationResource.toString()} chat=${chat.toString()} model=${options?.model?.id ?? '(none)'} cwd=${options?.workingDirectories?.[0]?.toString() ?? '(none)'}`); - - // Registered up front (both the fresh-create and rebind paths reach - // here) so the configuration scope's ref count always reflects every - // chat this agent has ever bound to it until `_disposeChat` untracks it. - this._trackConfigScopeChat(context.configurationResource, chat); // A create for a chat that already has a backing — a workbench rebind // after a chip-selection change, or a retried create. Refresh the @@ -3713,9 +4165,18 @@ export class CodexAgent extends Disposable implements IAgent { // never new, so there is nothing here to roll back. const boundSessionId = this._sessionIdByChatUri.get(chat.toString()); if (boundSessionId !== undefined) { - return this._rebindChat(boundSessionId, context, target, options); + const result = await this._rebindChat(boundSessionId, context, target, options); + // Commit the scope move only after the rebind succeeds. Otherwise a + // failed active-client refresh would strand the existing chat in the + // new scope even though its rebind was rejected. + await this._moveConfigScopeChat(context.configurationResource, chat); + return result; } + // Fresh creations are registered before any fallible work so the catch + // below can release the exact scope ref they acquired. + this._trackConfigScopeChat(context.configurationResource, chat); + try { // Codex has no SDK-level conversation-import primitive: unlike fork // (a `thread/fork` of an existing thread), there is no way to seed a @@ -3726,11 +4187,13 @@ export class CodexAgent extends Disposable implements IAgent { throw new Error('Codex does not support importing an existing conversation into a new chat.'); } - // Populate the catalog before any path validates a model selection, so - // a model picked before models finished loading isn't dropped. - if (this._models.get().length === 0 && this._modelsRefreshPromise) { - await this._modelsRefreshPromise; + // Selecting the Codex harness activates it. Populate the catalog before + // any path validates a model selection, so a model picked before models + // finished loading isn't dropped. + if (this._models.get().length === 0) { + await this.refreshModels(); } + this._throwIfShuttingDown(); const adoptedSessionId = this._hasSessionBacking(owningSessionId) ? undefined : owningSessionId; const session = options?.fork ? await this._forkChatBacking(options.fork, options, adoptedSessionId, target) @@ -3739,19 +4202,18 @@ export class CodexAgent extends Disposable implements IAgent { : await this._startChatBacking(context, options, target); try { + this._throwIfShuttingDown(); // Seed the eager active client over the exact chat this call binds // — the agent never invents a chat URI to stand in for it — before // the prewarm below reads the client's tools into a `thread/start`. await this._seedEagerActiveClient(session.sessionUri, chat, context, options?.activeClient); + this._throwIfShuttingDown(); if (session.threadId === undefined) { this._schedulePrewarm(session); } // Server tools are session-scoped, so they are advertised on the // session Agent Host addressed — the only URI it knows this chat by. - if (!session.serverToolsAdvertised && this._serverToolHost) { - session.serverToolsAdvertised = true; - this._serverToolHost.advertise(context.configurationResource.toString()); - } + this._advertiseServerTools(session, context.configurationResource); } catch (err) { // The backing (and, if this was its adopted identity, the session // itself) is already registered at this point — undo it exactly as @@ -3774,12 +4236,16 @@ export class CodexAgent extends Disposable implements IAgent { * server-tool advertise) fails. Mirrors the destructive * {@link _disposeChat} path exactly — same active-client handle removal, * same {@link _teardownSessionInMemory} teardown (pending registries, - * MCP controller, timers, managed working directory, OTel trace context) - * — because a runtime a failed create leaves behind is indistinguishable - * from one a caller created and immediately disposed. + * MCP controller, timers, managed working directory, OTel trace context) — + * but first archives a backing thread minted by the failed call. The host + * never committed that chat, so leaving its rollout merely unsubscribed + * would surface an orphan through native thread discovery later. */ private async _rollbackRegisteredChatCreation(session: ICodexSession, chat: URI): Promise { this._removeActiveClientHandlesForChat(chat); + if (session.threadId !== undefined) { + await this._archiveThreadBestEffort(session.threadId, 'chat creation failed'); + } await this._teardownSessionInMemory(session, session.sessionId, true); this._sessionIdByChatUri.delete(chat.toString()); } @@ -3806,15 +4272,36 @@ export class CodexAgent extends Disposable implements IAgent { }), }; } - if (options?.model) { - existing.model = this._resolveCreationModel(options.model) ?? existing.model; + // Validate the requested model before changing the live runtime. The eager + // client seed needs to observe the replacement configuration scope while it + // syncs customizations, but that sync is fallible, so every provisional + // field change below must be restored if it rejects. + const model = options?.model ? this._resolveCreationModel(options.model) : existing.model; + const previous = { + model: existing.model, + agent: existing.agent, + configurationResource: existing.configurationResource, + chatChannel: existing.chatChannel, + serverToolsAdvertisement: existing.serverToolsAdvertisement, + }; + try { + existing.model = model; + if (options?.agent) { + existing.agent = options.agent; + } + existing.configurationResource = context.configurationResource; + this._recordChatTarget(target.resource, existing.sessionUri); + await this._seedEagerActiveClient(existing.sessionUri, target.resource, context, options?.activeClient); + this._throwIfShuttingDown(); + this._advertiseServerTools(existing, context.configurationResource); + } catch (error) { + existing.model = previous.model; + existing.agent = previous.agent; + existing.configurationResource = previous.configurationResource; + existing.chatChannel = previous.chatChannel; + existing.serverToolsAdvertisement = previous.serverToolsAdvertisement; + throw error; } - if (options?.agent) { - existing.agent = options.agent; - } - existing.configurationResource = context.configurationResource; - this._recordChatTarget(target.resource, existing.sessionUri); - await this._seedEagerActiveClient(existing.sessionUri, target.resource, context, options?.activeClient); return this._createChatResult(context, existing); } @@ -3937,7 +4424,8 @@ export class CodexAgent extends Disposable implements IAgent { materializedEventFired: false, prewarmTimer: undefined, prewarmClaimed: false, - serverToolsAdvertised: false, + serverToolsAdvertisement: undefined, + publishedDirectoryCustomizationIds: new Set(), mcpController: undefined, clientCustomizations: new CodexClientCustomizationStore(), }; @@ -4013,11 +4501,12 @@ export class CodexAgent extends Disposable implements IAgent { dynamicTools, }); const threadId = startResult.thread.id; + const startedOnCurrentConnection = this._isCurrentConnection(conn); // The freshly started thread is live and subscribed, so build a // materialized (not resumed) entry keyed by the thread id. const session = this._createResumedSessionEntry(threadId, threadId, workingDirectory, model, target, undefined, undefined, options?.agent); - session.needsResume = false; + session.needsResume = !startedOnCurrentConnection; session.firstTurnSent = false; session.materializedEventFired = false; session.materializedMcpSig = mcpServersSignature(mcpServers); @@ -4045,7 +4534,12 @@ export class CodexAgent extends Disposable implements IAgent { * by the backing thread id and bind it to the chat URI before its history is * read. Its first send issues a `thread/resume`. */ - async materializeChat(chat: URI, context: URI | IAgentChatContext, providerData: string | undefined): Promise { + materializeChat(chat: URI, context: URI | IAgentChatContext, providerData: string | undefined): Promise { + return this._chatLifecycleSequencer.queue(chat.toString(), () => this._materializeChat(chat, context, providerData)); + } + + private async _materializeChat(chat: URI, context: URI | IAgentChatContext, providerData: string | undefined): Promise { + this._activate(); const operationContext = resolveAgentChatContext(context, chat); const target: ICodexTargetChat = { resource: chat, configurationResource: operationContext.configurationResource }; let decoded: ICodexPersistedChat | undefined; @@ -4061,48 +4555,87 @@ export class CodexAgent extends Disposable implements IAgent { return; } } - this._trackConfigScopeChat(operationContext.configurationResource, chat); + const previousScope = this._configScopeByChat.get(chat.toString()); + const previousBinding = this._sessionIdByChatUri.get(chat.toString()); const sessionId = decoded.sessionId; - const existing = this._sessions.get(sessionId); - if (existing) { - existing.chatChannel = chat; - existing.configurationResource = operationContext.configurationResource; - this._sessionIdByChatUri.set(chat.toString(), existing.sessionId); - return providerData === undefined ? { providerData: encodeCodexChat(decoded) } : undefined; - } - const sessionUri = AgentSession.uri(this.id, sessionId); - const overlay = await this._metadataStore.read(sessionUri); - const threadId = overlay.threadId ?? sessionId; - // The explicit path is the only thing a destructive teardown may ever - // delete; `overlay.cwd` is the session's current working directory - // regardless of who picked it and must never be treated as a managed - // folder on the strength of a (possibly stale) ownership flag alone. - const managedWorkingDirectory = this._releasedManagedWorkingDirectories.get(sessionId) ?? overlay.managedWorkingDirectory; - const workingDirectory = overlay.cwd ?? managedWorkingDirectory; - if (this._models.get().length === 0) { - await this.refreshModels(); - } - const model = this._supportedModelOrUndefined(overlay.modelId ? { id: overlay.modelId } : decoded.model); - // Codex's session id == thread id convention: the backing thread already - // exists on the app-server, so the entry resumes on first send. - const session = this._createResumedSessionEntry(sessionId, threadId, workingDirectory, model, target, undefined, undefined, overlay.agent); - if (managedWorkingDirectory) { - session.managedWorkingDirectory = managedWorkingDirectory; - } - this._releasedManagedWorkingDirectories.delete(sessionId); - this._sessions.set(sessionId, session); - this._sessionIdByThreadId.set(threadId, sessionId); - this._sessionIdByChatUri.set(chat.toString(), sessionId); - if (!session.serverToolsAdvertised && this._serverToolHost) { - session.serverToolsAdvertised = true; - this._serverToolHost.advertise(operationContext.configurationResource.toString()); - } - if (providerData === undefined) { - return { providerData: encodeCodexChat(decoded) }; + let existing: ICodexSession | undefined; + let previousExistingState: { readonly chatChannel: URI | undefined; readonly configurationResource: URI; readonly serverToolsAdvertisement: string | undefined } | undefined; + let session: ICodexSession | undefined; + try { + await this._moveConfigScopeChat(operationContext.configurationResource, chat); + this._throwIfShuttingDown(); + existing = this._sessions.get(sessionId); + if (existing) { + previousExistingState = { + chatChannel: existing.chatChannel, + configurationResource: existing.configurationResource, + serverToolsAdvertisement: existing.serverToolsAdvertisement, + }; + existing.chatChannel = chat; + existing.configurationResource = operationContext.configurationResource; + this._sessionIdByChatUri.set(chat.toString(), existing.sessionId); + this._advertiseServerTools(existing, operationContext.configurationResource); + return providerData === undefined ? { providerData: encodeCodexChat(decoded) } : undefined; + } + const sessionUri = AgentSession.uri(this.id, sessionId); + const overlay = await this._metadataStore.read(sessionUri); + this._throwIfShuttingDown(); + const threadId = overlay.threadId ?? sessionId; + // The explicit path is the only thing a destructive teardown may ever + // delete; `overlay.cwd` is the session's current working directory + // regardless of who picked it and must never be treated as a managed + // folder on the strength of a (possibly stale) ownership flag alone. + const managedWorkingDirectory = this._releasedManagedWorkingDirectories.get(sessionId) ?? overlay.managedWorkingDirectory; + const workingDirectory = overlay.cwd ?? managedWorkingDirectory; + if (this._models.get().length === 0) { + await this.refreshModels(); + } + this._throwIfShuttingDown(); + const model = this._supportedModelOrUndefined(overlay.modelId ? { id: overlay.modelId } : decoded.model); + // Codex's session id == thread id convention: the backing thread already + // exists on the app-server, so the entry resumes on first send. + session = this._createResumedSessionEntry(sessionId, threadId, workingDirectory, model, target, undefined, undefined, overlay.agent); + if (managedWorkingDirectory) { + session.managedWorkingDirectory = managedWorkingDirectory; + } + this._releasedManagedWorkingDirectories.delete(sessionId); + this._sessions.set(sessionId, session); + this._sessionIdByThreadId.set(threadId, sessionId); + this._sessionIdByChatUri.set(chat.toString(), sessionId); + this._advertiseServerTools(session, operationContext.configurationResource); + if (providerData === undefined) { + return { providerData: encodeCodexChat(decoded) }; + } + } catch (error) { + if (existing && previousExistingState) { + existing.chatChannel = previousExistingState.chatChannel; + existing.configurationResource = previousExistingState.configurationResource; + existing.serverToolsAdvertisement = previousExistingState.serverToolsAdvertisement; + } + if (session && this._sessions.get(sessionId) === session) { + if (session.managedWorkingDirectory) { + this._releasedManagedWorkingDirectories.set(sessionId, session.managedWorkingDirectory); + } + await this._teardownSessionInMemory(session, sessionId, false); + } + if (previousBinding === undefined) { + this._sessionIdByChatUri.delete(chat.toString()); + } else { + this._sessionIdByChatUri.set(chat.toString(), previousBinding); + } + const currentScope = this._configScopeByChat.get(chat.toString()); + if (currentScope !== undefined) { + this._untrackConfigScopeChat(URI.parse(currentScope), chat); + } + if (previousScope !== undefined) { + this._trackConfigScopeChat(URI.parse(previousScope), chat); + } + throw error; } } async recoverLegacyChat(chat: URI, context: URI | IAgentChatContext): Promise { + this._activate(); const operationContext = resolveAgentChatContext(context, chat); const sessionId = AgentSession.id(operationContext.configurationResource); this._recordChatTarget(chat, AgentSession.uri(this.id, sessionId)); @@ -4124,10 +4657,23 @@ export class CodexAgent extends Disposable implements IAgent { if (!activeClient) { return; } + const key = `${chat.toString()}\u0000${activeClient.clientId}`; + const hadHandle = this._activeClientHandles.has(key); const handle = this.getOrCreateActiveClient(chat, context, { clientId: activeClient.clientId, displayName: activeClient.displayName }); - handle.tools = activeClient.tools; - if (activeClient.customizations !== undefined) { - await this._syncClientCustomizations(sessionUri, activeClient.clientId, activeClient.customizations, { quiet: true }); + const previousTools = handle.tools; + try { + handle.tools = activeClient.tools; + if (activeClient.customizations !== undefined) { + await this._syncClientCustomizations(sessionUri, activeClient.clientId, activeClient.customizations, { quiet: true }); + handle.commitCustomizations(activeClient.customizations); + } + } catch (error) { + handle.tools = previousTools; + if (!hadHandle) { + handle.remove(); + this._activeClientHandles.delete(key); + } + throw error; } } @@ -4189,7 +4735,8 @@ export class CodexAgent extends Disposable implements IAgent { materializedEventFired: true, prewarmTimer: undefined, prewarmClaimed: true, - serverToolsAdvertised: false, + serverToolsAdvertisement: undefined, + publishedDirectoryCustomizationIds: new Set(), mcpController: undefined, clientCustomizations: new CodexClientCustomizationStore(), }; @@ -4220,13 +4767,16 @@ export class CodexAgent extends Disposable implements IAgent { if (!sourceSessionUri) { throw new Error(`Cannot fork codex chat ${fork.source.toString()}: backing thread could not be resolved`); } + const sourceSession = this._sessions.get(AgentSession.id(sourceSessionUri)); + if (sourceSession?.needsResume) { + await this._resumeSession(sourceSession); + } const sourceRead = await this._readSession(sourceSessionUri); if (!sourceRead) { throw new Error(`Cannot fork codex chat ${fork.source.toString()}: source thread could not be read`); } const sourceThreadId = sourceRead.thread.id; const sourceTurns = sourceRead.thread.turns ?? []; - const sourceSession = this._sessions.get(AgentSession.id(sourceSessionUri)); const sourceOverlay = sourceSession ? undefined : await this._metadataStore.read(sourceSessionUri); const sourceManagedWorkingDirectory = sourceSession?.managedWorkingDirectory ?? this._releasedManagedWorkingDirectories.get(AgentSession.id(sourceSessionUri)) @@ -4259,7 +4809,6 @@ export class CodexAgent extends Disposable implements IAgent { } const { keepThroughIndex, numTurnsToDrop } = boundary; - const conn = await this._ensureConnection(); const inheritedModel = sourceSession?.model ?? (sourceRead.persistedModelId ? { id: sourceRead.persistedModelId } : undefined) ?? this._models.get().find(candidate => parseCodexModelSelection(candidate).modelProvider === sourceRead.thread.modelProvider); @@ -4290,8 +4839,15 @@ export class CodexAgent extends Disposable implements IAgent { } } let forkResult: ThreadForkResponse; + let forkConnection: IConnectionReady; try { - forkResult = await conn.client.request<'thread/fork', ThreadForkResponse>('thread/fork', { + // Directory preparation and source inspection above may outlive the + // app-server that resumed the source. Revalidate immediately before the + // thread-scoped request so a replacement is resumed first. + forkConnection = sourceSession + ? (await this._ensureThreadConnection(sourceSession)).connection + : await this._ensureConnection(); + forkResult = await forkConnection.client.request<'thread/fork', ThreadForkResponse>('thread/fork', { threadId: sourceThreadId, ...(forkManagedWorkingDirectory ? { cwd: forkManagedWorkingDirectory.fsPath, @@ -4320,15 +4876,11 @@ export class CodexAgent extends Disposable implements IAgent { // and reject rather than returning a session with the wrong history. if (numTurnsToDrop > 0) { try { - await conn.client.request<'thread/rollback'>('thread/rollback', { threadId: newThreadId, numTurns: numTurnsToDrop }); + await forkConnection.client.request<'thread/rollback'>('thread/rollback', { threadId: newThreadId, numTurns: numTurnsToDrop }); } catch (err) { const message = err instanceof Error ? err.message : String(err); this._logService.warn(`[Codex:${newThreadId}] fork rollback failed (numTurns=${numTurnsToDrop}); discarding fork: ${message}`); - try { - await conn.client.request<'thread/archive'>('thread/archive', { threadId: newThreadId }); - } catch (archiveErr) { - this._logService.warn(`[Codex:${newThreadId}] failed to archive orphaned fork after rollback failure: ${archiveErr instanceof Error ? archiveErr.message : String(archiveErr)}`); - } + await this._archiveThreadBestEffort(newThreadId, 'fork rollback failed', forkConnection); if (forkManagedWorkingDirectory) { await this._removeManagedWorkingDirectory(forkManagedWorkingDirectory); } @@ -4375,13 +4927,7 @@ export class CodexAgent extends Disposable implements IAgent { this._sessionIdByChatUri.set(target.resource.toString(), sessionId); this._flushPendingMcpStartupStatuses(newThreadId); this._applyMcpInventoryToSession(session); - void this._refreshMcpInventory(conn.client, newThreadId); - // Forked threads skip materialization (the thread already exists), so - // advertise the server tools here for client-side parity. - if (!session.serverToolsAdvertised && this._serverToolHost) { - session.serverToolsAdvertised = true; - this._serverToolHost.advertise(target.configurationResource.toString()); - } + void this._refreshMcpInventory(forkConnection.client, newThreadId); this._persistMaterializedSession(session); // Seed the host→codex turn-id map for the copied turns so a later @@ -4428,6 +4974,7 @@ export class CodexAgent extends Disposable implements IAgent { if (session.disposed || !session.chatChannel) { return; } + this._advertiseServerTools(session, configResource); if (session.threadId !== undefined) { if (fireMaterializedEvent) { this._fireMaterialized(session); @@ -4493,18 +5040,31 @@ export class CodexAgent extends Disposable implements IAgent { return; } await this._customizationEnablementService.initializeSession(configResource.toString()); + if (session.disposed || !session.chatChannel) { + return; + } if (!session.workingDirectory) { // No working directory was supplied (e.g. an editor window with no // workspace folder open). Codex requires one, so create a managed // per-session temp folder and remember it for cleanup on dispose. - session.workingDirectory = await this._createManagedWorkingDirectory(session.sessionId); - session.managedWorkingDirectory = session.workingDirectory; + const managedWorkingDirectory = await this._createManagedWorkingDirectory(session.sessionId); + if (session.disposed || !session.chatChannel) { + await this._removeManagedWorkingDirectory(managedWorkingDirectory); + return; + } + session.workingDirectory = managedWorkingDirectory; + session.managedWorkingDirectory = managedWorkingDirectory; this._logService.info(`[Codex] no working directory supplied for session=${session.sessionUri.toString()}; using managed temp folder ${session.workingDirectory.fsPath}`); } await this._refreshSessionMcpDiscovery(session); - const conn = await this._ensureConnection(); + if (session.disposed || !session.chatChannel) { + return; + } const config = this._readSessionConfig(configResource); const model = await this._resolveModel(session); + if (session.disposed || !session.chatChannel) { + return; + } const { approvalPolicy, sandboxMode, approvalsReviewer } = this._resolveSessionPermissions(configResource); // Attach the session's MCP servers per-thread (verified: codex starts // them for this thread only): the workbench's root `mcpServers` config @@ -4513,6 +5073,9 @@ export class CodexAgent extends Disposable implements IAgent { // Mid-session MCP enablement changes apply only when Codex starts or resumes a thread. const mcpServers = this._buildSessionMcpServers(session); const customizationLaunch = await this._buildCustomizationLaunch(session); + if (session.disposed || !session.chatChannel) { + return; + } const resolvedModel = parseCodexModelSelection(model); const threadConfig: Record = { web_search: narrowWebSearchMode(config[CodexSessionConfigKey.WebSearchMode]) ?? codexSessionConfigDefaults[CodexSessionConfigKey.WebSearchMode], @@ -4530,6 +5093,12 @@ export class CodexAgent extends Disposable implements IAgent { ...(multiRootActive ? await this._selectedCapabilityRoots(session) : []), ...customizationLaunch.selectedCapabilityRoots, ]; + if (session.disposed || !session.chatChannel) { + return; + } + // Resolve the process only after every filesystem/configuration await so a + // connection that died during preparation is never used for thread/start. + const conn = await this._ensureConnection(); const startResult = await conn.client.request<'thread/start', ThreadStartResponse>('thread/start', { cwd: session.workingDirectory.fsPath, ...(runtimeWorkspaceRoots?.length ? { runtimeWorkspaceRoots } : {}), @@ -4544,24 +5113,25 @@ export class CodexAgent extends Disposable implements IAgent { dynamicTools: this._buildDynamicTools(session), }, this._traceContext(session)); const threadId = startResult.thread.id; + const startedOnCurrentConnection = this._isCurrentConnection(conn); if (multiRootActive && !session.workingDirectories && startResult.runtimeWorkspaceRoots?.length) { session.workingDirectories = startResult.runtimeWorkspaceRoots.map(path => URI.file(path)); session.workingDirectory = session.workingDirectories[0]; } if (session.disposed) { - try { - await conn.client.request<'thread/unsubscribe'>('thread/unsubscribe', { threadId }); - } catch (err) { - this._logService.info(`[Codex:${threadId}] thread/unsubscribe after disposed prewarm failed: ${err instanceof Error ? err.message : String(err)}`); - } + // A provisional runtime is only marked disposed by destructive chat + // deletion; idle release deliberately leaves it resident. The late + // thread therefore has no durable host chat and must be archived, not + // merely unsubscribed (which would expose an orphan in discovery). + await this._archiveThreadBestEffort(threadId, 'chat was disposed while thread/start was in flight', conn); return; } session.threadId = threadId; + session.needsResume = !startedOnCurrentConnection; session.materializedMcpSig = mcpServersSignature(mcpServers); session.materializedCustomizationsSig = customizationLaunch.signature; session.materializedToolsSig = toolsSignature(session.clientToolSet.merged()); session.materializedModelProvider = resolvedModel.modelProvider; - this._logService.info(`[Codex DEBUG] materialized session=${session.sessionUri.toString()} threadId=${session.threadId}`); this._sessionIdByThreadId.set(session.threadId, session.sessionId); this._flushPendingMcpStartupStatuses(session.threadId); this._applyMcpInventoryToSession(session); @@ -4569,10 +5139,7 @@ export class CodexAgent extends Disposable implements IAgent { // them as server-provided. Execution happens in-process via // `_handleDynamicToolCallRpc`; the tools were registered with codex in // the `dynamicTools` of the `thread/start` above. - if (!session.serverToolsAdvertised && this._serverToolHost) { - session.serverToolsAdvertised = true; - this._serverToolHost.advertise(configResource.toString()); - } + this._advertiseServerTools(session, configResource); // Surface workspace agents and the skills/hooks codex loaded for this // working directory in the Customizations view now that the connection is // ready and the cwd is known. Best-effort and fire-and-forget. @@ -4801,7 +5368,6 @@ export class CodexAgent extends Disposable implements IAgent { if (!sessionUri) { throw new Error(`Codex conversation is not bound: ${chat.toString()}`); } - this._logService.info(`[Codex DEBUG] sendMessage session=${sessionUri.toString()} prompt=${JSON.stringify(prompt).slice(0, 60)}`); const sessionId = AgentSession.id(sessionUri); const session = this._sessions.get(sessionId); if (!session) { @@ -4829,7 +5395,6 @@ export class CodexAgent extends Disposable implements IAgent { : workingDirectories; } await this._refreshSessionMcpDiscovery(session); - const conn = await this._ensureConnection(); const effectiveTurnId = turnId ?? generateUuid(); // Materialize the addressed Codex thread on first send. @@ -4850,6 +5415,10 @@ export class CodexAgent extends Disposable implements IAgent { this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId, duration }); return; } + // Materialization acquires its own connection and may race with a process + // exit. Resolve the connection only after it completes so the remainder of + // this send never retains the pre-materialization client. + let conn = await this._ensureConnection(); // Check needsResume before the resume block clears it so restored sessions never receive a late baseline. if (!session.firstTurnSent && !session.needsResume) { @@ -4894,39 +5463,46 @@ export class CodexAgent extends Disposable implements IAgent { // reloads its roles and developer instructions without losing history. this._markSessionForReload(session); } - if (session.needsResume) { - try { + try { + if (session.needsResume) { await this._resumeSession(session, conn); - } catch (err) { - const duration = this._clearTurnStopWatch(session); - this._fire(sessionUri, { - type: ActionType.ChatError, - turnId: effectiveTurnId, - duration, - part: { - kind: ResponsePartKind.Error, - error: { - errorType: 'CodexResumeFailed', - message: err instanceof Error ? err.message : String(err), - }, - }, - }); - this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId, duration }); - return; } + // `_resumeSession` may have retried on a replacement process. Carry the + // exact connection that now owns the loaded thread into turn preparation. + conn = (await this._ensureThreadConnection(session, conn)).connection; + } catch (err) { + const duration = this._clearTurnStopWatch(session); + this._fire(sessionUri, { + type: ActionType.ChatError, + turnId: effectiveTurnId, + duration, + part: { + kind: ResponsePartKind.Error, + error: { + errorType: 'CodexResumeFailed', + message: err instanceof Error ? err.message : String(err), + }, + }, + }); + this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId, duration }); + return; } - // Buffer the prompt text for `turn/started`'s userMessage fallback. - session.lastPromptText = prompt; - session.currentTurnId = effectiveTurnId; - session.modifiedTime = Date.now(); - this._startTurnStopWatch(session); let cleanupPaths: readonly string[] = []; + let turnRequestStarted = false; const isCompactCommand = parseLeadingSlashCommand(prompt)?.command === CODEX_COMPACT_SLASH_COMMAND; try { if (isCompactCommand) { await this._ensureCurrentLaunchBeforeTurn(session, configResource, conn); + conn = (await this._ensureThreadConnection(session, conn)).connection; const threadId = session.threadId!; + // Claim the host turn only once every reconnect-prone preparation step + // has completed. From here, connection-loss handling owns finalization. + session.lastPromptText = prompt; + session.currentTurnId = effectiveTurnId; + session.modifiedTime = Date.now(); + this._startTurnStopWatch(session); + turnRequestStarted = true; await conn.client.request<'thread/compact/start'>('thread/compact/start', { threadId }, this._traceContext(session)); session.firstTurnSent = true; return; @@ -4936,9 +5512,15 @@ export class CodexAgent extends Disposable implements IAgent { const model = await this._resolveModel(session); const resolvedModel = parseCodexModelSelection(model); const currentCustomizationLaunch = await this._ensureCurrentLaunchBeforeTurn(session, configResource, conn); + conn = (await this._ensureThreadConnection(session, conn)).connection; const threadId = session.threadId!; const turnOptions = this._turnStartOptions(session, resolvedModel.modelId, currentCustomizationLaunch.developerInstructions, configResource); const hostInstructions = resolveAgentHostInstructions(operationContext); + session.lastPromptText = prompt; + session.currentTurnId = effectiveTurnId; + session.modifiedTime = Date.now(); + this._startTurnStopWatch(session); + turnRequestStarted = true; await conn.client.request<'turn/start'>('turn/start', { threadId, input: resolvedInput.input.slice(), @@ -4956,6 +5538,15 @@ export class CodexAgent extends Disposable implements IAgent { // We don't await turn completion here — the notification // stream emits ChatTurnComplete asynchronously. } catch (err) { + // A transport exit finalizes and clears an owned turn in + // `_handleConnectionLost`. Do not start or complete it a second time. + if (turnRequestStarted && session.currentTurnId !== effectiveTurnId) { + return; + } + if (turnRequestStarted) { + session.currentTurnId = undefined; + session.currentAppTurnId = undefined; + } if (err instanceof CancellationError) { this._fire(sessionUri, { type: ActionType.ChatTurnCancelled, turnId: effectiveTurnId, duration: this._clearTurnStopWatch(session) }); return; @@ -5140,6 +5731,13 @@ export class CodexAgent extends Disposable implements IAgent { const operationContext = resolveAgentChatContext(context, chat); const runtimeSession = this._resolveConversationSession(chat, operationContext); this._removeActiveClientHandlesForChat(chat); + // Stop new chat-addressed work from reaching this runtime before checking + // whether its released resources are still retained by a durable binding. + // In particular, `_reclaimManagedWorkingDirectoryIfNotLive` deliberately + // treats a binding as live, so destructive disposal must drop it first. + if (runtimeSession) { + this._sessionIdByChatUri.delete(chat.toString()); + } // Configuration-scope ref tracking is independent of whether a // runtime is currently resolvable for `chat` — an unaddressable chat // still occupied a slot in its scope's ref set when it was created. @@ -5148,7 +5746,6 @@ export class CodexAgent extends Disposable implements IAgent { return; } await this._disposeRuntimeSession(runtimeSession, true); - this._sessionIdByChatUri.delete(chat.toString()); } private async _releaseChat(chat: URI, context: URI | IAgentChatContext): Promise { @@ -5295,33 +5892,35 @@ export class CodexAgent extends Disposable implements IAgent { if (!sessionUri) { return; } - const session = this._sessions.get(AgentSession.id(sessionUri)); - if (session) { - const supported = this._supportedModelOrUndefined(model); - if (!supported) { - throw new Error(`Codex model '${model.id}' is not available.`); - } - const previousProvider = session.materializedModelProvider ?? (session.model ? parseCodexModelSelection(session.model).modelProvider : undefined); - const nextProvider = parseCodexModelSelection(supported).modelProvider; - this._ensureModelProviderAuthenticated(supported); - session.model = supported; - if (previousProvider !== undefined && previousProvider !== nextProvider) { - this._resetSessionForModelProviderChange(session, nextProvider); - } - await this._persistSessionModel(session); - this._persistMaterializedSession(session); + const supported = this._supportedModelOrUndefined(model); + if (!supported) { + throw new Error(`Codex model '${model.id}' is not available.`); } + this._ensureModelProviderAuthenticated(supported); + const session = this._sessions.get(AgentSession.id(sessionUri)); + if (!session) { + // Idle eviction drops only the in-memory runtime; its exact chat binding + // remains. Persist the selection so reopening that chat restores the + // model the user just chose instead of silently retaining the old one. + await this._metadataStore.write(sessionUri, { modelId: supported.id }); + return; + } + const previousProvider = session.materializedModelProvider ?? (session.model ? parseCodexModelSelection(session.model).modelProvider : undefined); + const nextProvider = parseCodexModelSelection(supported).modelProvider; + session.model = supported; + if (previousProvider !== undefined && previousProvider !== nextProvider) { + await this._resetSessionForModelProviderChange(session, nextProvider); + } + await this._persistSessionModel(session); + this._persistMaterializedSession(session); } /** * Truncate the chat Agent Host addresses, not the session it belongs to. * * Codex backs every chat with its own thread, so the rollback target is the - * runtime bound to `chat` — resolved through the recorded binding or the - * host-supplied context, never by re-deriving membership from a URI. When - * `chat` is omitted (a session-addressed caller) the session's own runtime - * is the target, which is also what an unresolvable chat falls back to via - * the host context's owning session. + * runtime bound to `chat` — resolved through the recorded binding, never by + * re-deriving membership from its configuration scope or URI shape. * * Codex rolls back by a count of trailing turns. Resolve how many turns * follow `turnId` (or all of them when omitted) from the persisted thread, @@ -5333,6 +5932,10 @@ export class CodexAgent extends Disposable implements IAgent { if (!targetUri) { return; } + const targetSession = this._sessions.get(AgentSession.id(targetUri)); + if (targetSession?.needsResume) { + await this._resumeSession(targetSession); + } const read = await this._readSession(targetUri); if (!read) { return; @@ -5348,8 +5951,7 @@ export class CodexAgent extends Disposable implements IAgent { // A live session's workbench turn id maps to a codex turn id; a // restored session already uses codex turn ids, so fall back to the // id as-is on a miss. - const session = this._sessions.get(AgentSession.id(targetUri)); - const codexTurnId = session?.codexTurnIdByHostTurnId.get(turnId) ?? turnId; + const codexTurnId = targetSession?.codexTurnIdByHostTurnId.get(turnId) ?? turnId; const index = turns.findIndex(t => t.id === codexTurnId); if (index === -1) { this._logService.warn(`[Codex] truncateChat: turnId ${turnId} not found in thread ${read.thread.id}; skipping`); @@ -5361,7 +5963,9 @@ export class CodexAgent extends Disposable implements IAgent { return; } try { - const conn = await this._ensureConnection(); + const conn = targetSession + ? (await this._ensureThreadConnection(targetSession)).connection + : await this._ensureConnection(); await conn.client.request<'thread/rollback'>('thread/rollback', { threadId: read.thread.id, numTurns }); } catch (err) { this._logService.warn(`[Codex:${read.thread.id}] thread/rollback failed: ${err instanceof Error ? err.message : String(err)}`); @@ -5373,29 +5977,27 @@ export class CodexAgent extends Disposable implements IAgent { if (threadId === undefined) { return; } - const conn = this._connection; - if (conn.kind !== 'ready') { - return; - } try { - if (isArchived) { - await conn.client.request<'thread/archive'>('thread/archive', { threadId }); - } else { - await conn.client.request<'thread/unarchive'>('thread/unarchive', { threadId }); - } + await this._withOnDemandConnection(async client => { + if (isArchived) { + await client.request<'thread/archive'>('thread/archive', { threadId }); + } else { + await client.request<'thread/unarchive'>('thread/unarchive', { threadId }); + } + }); } catch (err) { this._logService.warn(`[Codex:${threadId}] thread/${isArchived ? 'archive' : 'unarchive'} failed: ${err instanceof Error ? err.message : String(err)}`); } } - /** Resolve the codex thread id for a session: in-memory → persisted overlay. */ + /** Resolve the codex thread id for a session: in-memory → persisted overlay → legacy URI identity. */ private async _resolveThreadId(sessionUri: URI): Promise { const existing = this._sessions.get(AgentSession.id(sessionUri)); if (existing?.threadId !== undefined) { return existing.threadId; } const overlay = await this._metadataStore.read(sessionUri); - return overlay.threadId; + return overlay.threadId ?? AgentSession.id(sessionUri); } respondToPermissionRequest(requestId: string, approved: boolean): void { @@ -5454,12 +6056,23 @@ export class CodexAgent extends Disposable implements IAgent { private async _resumeSession(session: ICodexSession, connection?: IConnectionReady): Promise { while (session.needsResume || session.resumePromise) { if (session.resumePromise) { - await session.resumePromise; + try { + await session.resumePromise; + } catch (error) { + if (error instanceof CodexConnectionReplacedError) { + connection = undefined; + continue; + } + throw error; + } continue; } const unsubscribeBeforeResume = session.unsubscribeBeforeResume; session.needsResume = false; session.unsubscribeBeforeResume = false; + const preferredConnection = connection; + connection = undefined; + let resumeConnection: IConnectionReady | undefined; session.resumePromise = (async () => { const threadId = session.threadId; if (!threadId) { @@ -5468,8 +6081,12 @@ export class CodexAgent extends Disposable implements IAgent { if (session.disposed) { throw new CancellationError(); } - const conn = connection ?? await this._ensureConnection(); + const conn = preferredConnection && this._isCurrentConnection(preferredConnection) + ? preferredConnection + : await this._ensureConnection(); + resumeConnection = conn; await this._refreshSessionMcpDiscovery(session); + this._assertCurrentConnection(conn); if (unsubscribeBeforeResume) { // `thread/resume` deliberately rejoins a loaded subscribed thread and // ignores conflicting overrides. Unsubscribe first so app-server @@ -5484,6 +6101,7 @@ export class CodexAgent extends Disposable implements IAgent { if (session.disposed) { throw new CancellationError(); } + this._assertCurrentConnection(conn); const resumeResult = await conn.client.request<'thread/resume', ThreadResumeResponse>( 'thread/resume', buildCodexResumeParams( @@ -5497,6 +6115,7 @@ export class CodexAgent extends Disposable implements IAgent { ), this._traceContext(session), ); + this._assertCurrentConnection(conn); if (session.disposed) { try { await conn.client.request<'thread/unsubscribe'>('thread/unsubscribe', { threadId }); @@ -5517,14 +6136,88 @@ export class CodexAgent extends Disposable implements IAgent { session.needsResume = true; session.unsubscribeBeforeResume ||= unsubscribeBeforeResume; } + if (err instanceof CodexConnectionReplacedError || (resumeConnection !== undefined && !this._isCurrentConnection(resumeConnection))) { + throw new CodexConnectionReplacedError(); + } throw err; }).finally(() => { session.resumePromise = undefined; }); - await session.resumePromise; + try { + await session.resumePromise; + } catch (error) { + if (error instanceof CodexConnectionReplacedError) { + continue; + } + throw error; + } } } + /** + * Return a thread id together with the exact persistent connection on which + * that thread is loaded. A reconnect between resume and the caller's request + * restarts the loop instead of handing an unloaded replacement to the caller. + */ + private async _ensureThreadConnection(session: ICodexSession, preferredConnection?: IConnectionReady): Promise<{ readonly threadId: string; readonly connection: IConnectionReady }> { + while (true) { + if (session.disposed) { + throw new CancellationError(); + } + const threadId = session.threadId; + if (!threadId) { + throw new Error(`Cannot use Codex session ${session.sessionId}: no backing thread`); + } + const connection = preferredConnection && this._isCurrentConnection(preferredConnection) + ? preferredConnection + : await this._ensureConnection(); + preferredConnection = undefined; + if (session.needsResume || session.resumePromise) { + await this._resumeSession(session, connection); + } + if (this._isCurrentConnection(connection) && !session.needsResume && !session.resumePromise && session.threadId === threadId) { + return { threadId, connection }; + } + } + } + + private _isCurrentConnection(connection: IConnectionReady): boolean { + return this._connection.kind === 'ready' && this._connection.client === connection.client; + } + + private _assertCurrentConnection(connection: IConnectionReady): void { + if (!this._isCurrentConnection(connection)) { + throw new CodexConnectionReplacedError(); + } + } + + /** + * Archive a thread that the host never committed, retrying once on the + * replacement app-server when the connection that created it has gone away. + * Cleanup is best-effort so its failure never hides the original create, + * fork, or disposal error. + */ + private async _archiveThreadBestEffort(threadId: string, reason: string, preferredConnection?: IConnectionReady): Promise { + let lastError = 'unknown error'; + for (let attempt = 0; attempt < 2; attempt++) { + let connection: IConnectionReady | undefined; + try { + connection = attempt === 0 && preferredConnection && this._isCurrentConnection(preferredConnection) + ? preferredConnection + : await this._ensureConnection(); + await connection.client.request<'thread/archive'>('thread/archive', { threadId }); + return; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + if (attempt === 0 && connection && !this._isCurrentConnection(connection) && !this._isShuttingDown && !this._store.isDisposed) { + continue; + } + break; + } + } + this._logService.warn(`[Codex:${threadId}] failed to archive backing after ${reason}: ${lastError}`); + } + private _markSessionForReload(session: ICodexSession): void { session.unsubscribeBeforeResume = true; session.needsResume = true; @@ -5543,7 +6236,13 @@ export class CodexAgent extends Disposable implements IAgent { * context's `configurationResource` names the session the host's server * tools are advertised on. */ - async getChatMetadata(chat: URI, context: URI | IAgentChatContext, providerData?: string): Promise { + async getChatMetadata(chat: URI, context: URI | IAgentChatContext, providerData?: string, options?: IAgentChatMetadataOptions): Promise { + // Session listing calls this method too, so metadata reads are passive by + // default. A restore is the host's explicit boundary for reopening an + // existing Codex session and may retain the app-server it needs. + if (options?.activation === 'restore') { + this._activate(); + } const session = resolveAgentChatContext(context, chat).configurationResource; const backing = providerData ? decodeCodexChat(providerData) : undefined; const sessionId = backing?.sessionId ?? AgentSession.id(session); @@ -5552,17 +6251,26 @@ export class CodexAgent extends Disposable implements IAgent { // threads is blocked waiting on a dynamic tool call — exactly the state // a session server tool (`get_current_session`) runs in. const live = this._sessions.get(sessionId); - if (live?.threadId) { + if (live) { + this._advertiseServerTools(live, session); return { chat, startTime: live.startTime, modifiedTime: live.modifiedTime, summary: live.summary, workingDirectories: live.workingDirectories ?? (live.workingDirectory ? [live.workingDirectory] : undefined), + ...(live.model ? { model: live.model } : {}), }; } + // Session listing is ambient. The host-owned registry supplies a stable + // fallback row that this lazy provider explicitly accepts until the user + // opens this Codex session; only then may the app-server be asked for + // authoritative thread metadata. + if (!this._activated) { + return options?.registryFallback ? { chat, ...options.registryFallback } : undefined; + } const backingUri = backing ? AgentSession.uri(this.id, backing.sessionId) : session; - const read = await this._readSession(backingUri); + const read = await this._readSession(backingUri, false); if (!read) { return undefined; } @@ -5603,33 +6311,39 @@ export class CodexAgent extends Disposable implements IAgent { this._sessionIdByThreadId.set(threadId, sessionId); if (restoredModel && parseCodexModelSelection(restoredModel).modelProvider !== materializedModelProvider) { this._pendingMcpStartupStatuses.delete(threadId); - this._resetSessionForModelProviderChange(restored, parseCodexModelSelection(restoredModel).modelProvider); + await this._resetSessionForModelProviderChange(restored, parseCodexModelSelection(restoredModel).modelProvider); } else { this._flushPendingMcpStartupStatuses(threadId); this._applyMcpInventoryToSession(restored); - if (this._connection.kind === 'ready') { - void this._refreshMcpInventory(this._connection.client, threadId); - } } // Compatible restored threads skip materialization because the thread // already exists. Incompatible ones rematerialize on the next send. // Either way, advertise server tools now for client-side parity — // on the session the host addressed, which is the only URI it knows. - if (!restored.serverToolsAdvertised && this._serverToolHost) { - restored.serverToolsAdvertised = true; - this._serverToolHost.advertise(session.toString()); - } + this._advertiseServerTools(restored, session); } return metadata; } - private _readSession(session: URI): Promise { + private _readSession(session: URI, includeTurns = true): Promise { + const readFromCurrentConnection = async (): Promise => { + while (!this._store.isDisposed) { + try { + return await this._doReadSession(session, includeTurns); + } catch (error) { + if (!(error instanceof CodexConnectionReplacedError)) { + throw error; + } + } + } + return undefined; + }; return this._sessions.has(AgentSession.id(session)) - ? this._doReadSession(session) - : this._coldSessionReadLimiter.queue(() => this._doReadSession(session)); + ? readFromCurrentConnection() + : this._coldSessionReadLimiter.queue(readFromCurrentConnection); } - private async _doReadSession(session: URI): Promise { + private async _doReadSession(session: URI, includeTurns: boolean): Promise { // Resolve the codex thread id for this session URI. Resolution // order: in-memory session → persisted metadata overlay → URI host. // The final `?? sessionId` is a LEGACY-COMPAT shim, not an active I3 @@ -5650,13 +6364,17 @@ export class CodexAgent extends Disposable implements IAgent { persistedWorkingDirectories = overlay.workingDirectories; persistedModelId = overlay.modelId; } - const conn = await this._ensureConnection(); + const conn = existing?.threadId + ? (await this._ensureThreadConnection(existing)).connection + : await this._ensureConnection(); const readThread = async (candidateThreadId: string): Promise => { const response = await conn.client.request<'thread/read', ThreadReadResponse>('thread/read', { threadId: candidateThreadId, - includeTurns: true, + includeTurns, }); + this._assertCurrentConnection(conn); const rolloutMetadata = await this._readCodexRolloutMetadata(response.thread); + this._assertCurrentConnection(conn); return { ...response, persistedWorkingDirectories, persistedModelId, rolloutMetadata }; }; try { @@ -5676,7 +6394,10 @@ export class CodexAgent extends Disposable implements IAgent { persistedModelId: originalModel?.id, }; } - } catch { + } catch (error) { + if (error instanceof CodexConnectionReplacedError) { + throw error; + } // The session URI is not itself a persisted Codex Desktop thread. } } @@ -5696,6 +6417,9 @@ export class CodexAgent extends Disposable implements IAgent { } return read; } catch (err) { + if (err instanceof CodexConnectionReplacedError) { + throw err; + } const message = err instanceof Error ? err.message : String(err); // `thread not loaded` is app-server's expected response for any // thread we have not yet resumed in this process; sendMessage's @@ -5719,6 +6443,9 @@ export class CodexAgent extends Disposable implements IAgent { request => conn.client.request<'thread/list', ThreadListResponse>('thread/list', request), collected => this._logService.warn(`[Codex] thread/list hit the ${THREAD_LIST_MAX_PAGES}-page cap after ${collected} threads; some sessions may be missing`), ); + if (!this._isCurrentConnection(conn)) { + return undefined; + } // Map persisted threads back to the URI the workbench already // knows them by. After `_materializeIfNeeded` runs, the codex // thread is persisted to disk under its thread id but the @@ -5733,7 +6460,7 @@ export class CodexAgent extends Disposable implements IAgent { liveUriByThreadId.set(s.threadId, s.sessionUri); } } - return Promise.all(threads.map(async thread => { + const metadata = await Promise.all(threads.map(async thread => { const sessionUri = liveUriByThreadId.get(thread.id) ?? AgentSession.uri(this.id, thread.id); const liveWorkingDirectories = this._sessions.get(AgentSession.id(sessionUri))?.workingDirectories; const isDesktop = thread.modelProvider === CODEX_OPENAI_MODEL_PROVIDER @@ -5742,6 +6469,7 @@ export class CodexAgent extends Disposable implements IAgent { const chat = URI.parse(buildDefaultChatUri(sessionUri)); return this._withWorkingDirectories(await this._threadToMetadata(thread, chat, undefined, isDesktop), liveWorkingDirectories); })); + return this._isCurrentConnection(conn) ? metadata : undefined; } catch (err) { // Discovery runs independently for every provider; a rejection here // should not take a sibling provider's discovery @@ -5754,6 +6482,12 @@ export class CodexAgent extends Disposable implements IAgent { } async listChatsToMigrate(): Promise { + // Registration-time migration is ambient. Report an empty initial catalog + // so provider registration can finish without starting Codex; activated + // discovery later emits both known (internal) and unknown (external) chats. + if (!this._activated) { + return []; + } // `undefined` is "can't enumerate yet", which is the honest answer while the // SDK is absent: the catalog lives inside it, but fetching one is the user's // call. {@link _restartChatDiscovery} revisits this once they make it. @@ -5773,8 +6507,14 @@ export class CodexAgent extends Disposable implements IAgent { } private _startCodexChatDiscovery(): Promise { + if (this._isShuttingDown || this._store.isDisposed || !this._activated) { + return Promise.resolve(); + } if (!this._codexChatDiscovery) { this._codexChatDiscovery = retry(async () => { + if (this._isShuttingDown || this._store.isDisposed) { + return; + } // Waits for the SDK rather than pulling it down — see // {@link listChatsToMigrate}. Returning leaves the retry loop happy, // since no amount of retrying will make the user press Download. @@ -5782,6 +6522,9 @@ export class CodexAgent extends Disposable implements IAgent { this._logService.info('[Codex] SDK not downloaded yet; deferring chat discovery'); return; } + if (this._isShuttingDown || this._store.isDisposed) { + return; + } if (!(await this._emitCodexChats())) { throw new Error('Codex chat catalog is not available'); } @@ -5793,6 +6536,9 @@ export class CodexAgent extends Disposable implements IAgent { /** Runs discovery again for whoever is still subscribed, after it deferred for want of an SDK. */ private _restartChatDiscovery(): void { + if (this._isShuttingDown || this._store.isDisposed) { + return; + } if (this._codexChatDiscovery) { this._codexChatDiscovery = undefined; void this._startCodexChatDiscovery(); @@ -5802,12 +6548,14 @@ export class CodexAgent extends Disposable implements IAgent { private async _emitCodexChats(): Promise { try { const chats = await this._listCodexChats(); - if (chats) { - const limiter = new Limiter(4); - const unknown = await Promise.all(chats.map(chat => limiter.queue(async () => { - return await this._isKnownCodexChat(chat) ? undefined : { ...chat, external: true }; + if (chats && !this._isShuttingDown && !this._store.isDisposed) { + const limiter = new Limiter(4); + const discovered = await Promise.all(chats.map(chat => limiter.queue(async () => { + return { ...chat, external: !(await this._isKnownCodexChat(chat)) }; }))); - const discovered = unknown.filter((chat): chat is IAgentDiscoveredChat => chat !== undefined); + if (this._isShuttingDown || this._store.isDisposed) { + return true; + } this._onDidDiscoverChats.fire(discovered); return true; } @@ -5899,6 +6647,15 @@ export class CodexAgent extends Disposable implements IAgent { this._serverToolHost = host; } + private _advertiseServerTools(session: ICodexSession, configurationResource: URI): void { + const resource = configurationResource.toString(); + if (!this._serverToolHost || session.serverToolsAdvertisement === resource) { + return; + } + this._serverToolHost.advertise(resource); + session.serverToolsAdvertisement = resource; + } + /** * `chat` is the one exact chat this handle contributes to — no fan-out to * chat-array membership or sibling inference; Agent Host calls this once @@ -5908,7 +6665,7 @@ export class CodexAgent extends Disposable implements IAgent { * Codex reconciles pushed plugin customizations via * {@link _syncClientCustomizations}. */ - getOrCreateActiveClient(chat: URI, context: URI | IAgentChatContext, client: { readonly clientId: string; readonly displayName?: string }, _hostCustomizations?: readonly Customization[]): IActiveClient { + getOrCreateActiveClient(chat: URI, context: URI | IAgentChatContext, client: { readonly clientId: string; readonly displayName?: string }, _hostCustomizations?: readonly Customization[]): CodexActiveClientHandle { const key = `${chat.toString()}\u0000${client.clientId}`; const existing = this._activeClientHandles.get(key); if (existing) { @@ -6024,6 +6781,15 @@ export class CodexAgent extends Disposable implements IAgent { return sequencer.queue(() => this._doReconcileMaterializedCustomizations(session)); } + private _queueDirectoryCustomizationOperation(session: ICodexSession, operation: () => Promise): Promise { + let sequencer = this._directoryCustomizationSequencers.get(session); + if (!sequencer) { + sequencer = new Sequencer(); + this._directoryCustomizationSequencers.set(session, sequencer); + } + return sequencer.queue(operation); + } + private async _doReconcileMaterializedCustomizations(session: ICodexSession): Promise { if (session.disposed) { return; @@ -6107,6 +6873,26 @@ export class CodexAgent extends Disposable implements IAgent { return [...byId.values()]; } + private _queueSkillHookCustomizationRefresh(client: ICodexAppServerClient): void { + if (this._connection.kind !== 'ready' || this._connection.client !== client) { + return; + } + // One extra-roots update can produce several catalog notifications. Coalesce + // them before issuing the cwd-scoped skills/list and hooks/list requests. + this._skillHookCustomizationRefresh.value = disposableTimeout(() => { + if (this._connection.kind !== 'ready' || this._connection.client !== client) { + return; + } + for (const session of this._sessions.values()) { + // Only threads loaded into this app-server have a live catalog to + // refresh. Cold restored sessions refresh when they are resumed. + if (!session.disposed && session.threadId !== undefined && !session.needsResume) { + void this._refreshSkillHookCustomizations(session); + } + } + }, 100); + } + /** * Recompute the process-global skill roots from every live session's * enabled client plugins and push them to codex via `skills/extraRoots/set`. @@ -6116,9 +6902,19 @@ export class CodexAgent extends Disposable implements IAgent { * ready; the next {@link _materialize} re-applies. */ private async _refreshSkillExtraRoots(): Promise { - if (this._connection.kind !== 'ready') { - return; - } + return this._skillExtraRootsSequencer.queue(async () => { + if (this._connection.kind !== 'ready') { + return; + } + await this._applySkillExtraRoots(this._connection.client); + }); + } + + private _queueSkillExtraRootsForClient(client: ICodexAppServerClient): Promise { + return this._skillExtraRootsSequencer.queue(() => this._applySkillExtraRoots(client)); + } + + private async _applySkillExtraRoots(client: ICodexAppServerClient): Promise { const plugins: ICodexClientPlugin[] = []; for (const session of this._sessions.values()) { if (!session.disposed) { @@ -6127,7 +6923,7 @@ export class CodexAgent extends Disposable implements IAgent { } const roots = codexSkillRootsFromPlugins(plugins); try { - await this._connection.client.request<'skills/extraRoots/set'>('skills/extraRoots/set', { extraRoots: roots }); + await client.request<'skills/extraRoots/set'>('skills/extraRoots/set', { extraRoots: roots }); if (roots.length > 0) { this._logService.info(`[Codex] applied ${roots.length} client-plugin skill root(s)`); } @@ -6158,24 +6954,38 @@ export class CodexAgent extends Disposable implements IAgent { if (!session) { return []; } - const controller = this._getOrCreateMcpController(session); - if (controller) { - controller.applyAll(inventoryToSdkServers(this._mcpInventory.forThread(session.threadId))); - this._refreshMcpCustomizationIds(session, controller); - } - const [workspaceAgents, skillHookContainers] = await Promise.all([ - discoverCodexWorkspaceAgents(this._workingDirectories(session), this._fileService), - this._fetchSkillHookContainers(session), - ]); - // Workspace custom agents come from the Agent Host's session-scoped - // scan. Client-pushed customizations remain for plugins/extensions, then - // codex's own MCP, skill, and hook catalogs complete the surface. - return [ - ...workspaceAgents.containers, - ...this._resolveClientCustomizationEnablement(session).resolution.customizations, - ...(controller?.topLevelCustomizations() ?? []), - ...skillHookContainers, - ]; + return this._queueDirectoryCustomizationOperation(session, async () => { + if (session.disposed) { + return []; + } + const catalogConnection = this._connection.kind === 'ready' ? this._connection : undefined; + const controller = this._getOrCreateMcpController(session); + if (controller) { + controller.applyAll(inventoryToSdkServers(this._mcpInventory.forThread(session.threadId))); + this._refreshMcpCustomizationIds(session, controller); + } + const [workspaceAgents, skillHookContainers] = await Promise.all([ + discoverCodexWorkspaceAgents(this._workingDirectories(session), this._fileService), + this._fetchSkillHookContainers(session), + ]); + if (session.disposed || (catalogConnection !== undefined && !this._isCurrentConnection(catalogConnection))) { + return []; + } + const directoryCustomizations = [...workspaceAgents.containers, ...skillHookContainers]; + session.publishedDirectoryCustomizationIds.clear(); + for (const customization of directoryCustomizations) { + session.publishedDirectoryCustomizationIds.add(customization.id); + } + // Workspace custom agents come from the Agent Host's session-scoped + // scan. Client-pushed customizations remain for plugins/extensions, then + // codex's own MCP, skill, and hook catalogs complete the surface. + return [ + ...workspaceAgents.containers, + ...this._resolveClientCustomizationEnablement(session).resolution.customizations, + ...(controller?.topLevelCustomizations() ?? []), + ...skillHookContainers, + ]; + }); } /** @@ -6210,19 +7020,35 @@ export class CodexAgent extends Disposable implements IAgent { * untouched. */ private async _refreshSkillHookCustomizations(session: ICodexSession): Promise { + return this._queueDirectoryCustomizationOperation(session, () => this._doRefreshSkillHookCustomizations(session)); + } + + private async _doRefreshSkillHookCustomizations(session: ICodexSession): Promise { if (session.disposed) { return; } + const catalogConnection = this._connection.kind === 'ready' ? this._connection : undefined; const [workspaceAgents, skillHookContainers] = await Promise.all([ discoverCodexWorkspaceAgents(this._workingDirectories(session), this._fileService), this._fetchSkillHookContainers(session), ]); - if (session.disposed) { + if (session.disposed || (catalogConnection !== undefined && !this._isCurrentConnection(catalogConnection))) { return; } - for (const container of [...workspaceAgents.containers, ...skillHookContainers]) { + const containers = [...workspaceAgents.containers, ...skillHookContainers]; + const nextIds = new Set(containers.map(container => container.id)); + for (const id of session.publishedDirectoryCustomizationIds) { + if (!nextIds.has(id)) { + this._fire(session.configurationResource, { type: ActionType.SessionCustomizationRemoved, id }); + } + } + for (const container of containers) { this._fire(session.configurationResource, { type: ActionType.SessionCustomizationUpdated, customization: container }); } + session.publishedDirectoryCustomizationIds.clear(); + for (const id of nextIds) { + session.publishedDirectoryCustomizationIds.add(id); + } } /** @@ -6257,8 +7083,7 @@ export class CodexAgent extends Disposable implements IAgent { if (!tool) { throw new Error(`tools/call missing 'name' parameter`); } - const threadId = await this._ensureThreadId(session); - const conn = await this._ensureConnection(); + const { threadId, connection: conn } = await this._ensureMaterializedThreadConnection(session); return conn.client.request<'mcpServer/tool/call', McpServerToolCallResponse>('mcpServer/tool/call', { threadId, server: serverName, @@ -6271,8 +7096,7 @@ export class CodexAgent extends Disposable implements IAgent { if (!uri) { throw new Error(`resources/read missing 'uri' parameter`); } - const threadId = await this._ensureThreadId(session); - const conn = await this._ensureConnection(); + const { threadId, connection: conn } = await this._ensureMaterializedThreadConnection(session); return conn.client.request<'mcpServer/resource/read', McpResourceReadResponse>('mcpServer/resource/read', { threadId, server: serverName, @@ -6291,8 +7115,7 @@ export class CodexAgent extends Disposable implements IAgent { this._logService.warn(`[Codex] Cannot start unknown MCP server customization ${id}`); return; } - const threadId = await this._ensureThreadId(session); - const conn = await this._ensureConnection(); + const { threadId, connection: conn } = await this._ensureMaterializedThreadConnection(session); await conn.client.request<'config/mcpServer/reload'>('config/mcpServer/reload', undefined); await this._refreshMcpInventory(conn.client, threadId); } @@ -6475,7 +7298,7 @@ export class CodexAgent extends Disposable implements IAgent { return; } // Drop the result if the connection was replaced while we were listing. - if (this._connection.kind === 'ready' && this._connection.client !== client) { + if (this._connection.kind !== 'ready' || this._connection.client !== client) { return; } const session = threadId === null ? undefined : this._sessionForMcpThread(threadId); @@ -6516,7 +7339,7 @@ export class CodexAgent extends Disposable implements IAgent { * server settle into starting/error/stopped promptly. */ private _handleMcpStartupStatus(client: ICodexAppServerClient, threadId: string | null, name: string, status: McpServerStartupState, error: string | null): void { - if (this._connection.kind === 'ready' && this._connection.client !== client) { + if (this._connection.kind !== 'ready' || this._connection.client !== client) { return; } if (threadId !== null && !this._sessionForMcpThread(threadId)) { @@ -6624,7 +7447,7 @@ export class CodexAgent extends Disposable implements IAgent { this._logService.warn(`[Codex] failed to discover OAuth metadata for MCP server '${name}' at ${url}; the Authenticate action may not be able to complete: ${err instanceof Error ? err.message : String(err)}`); } // Drop the result if the connection was replaced while discovering. - if (this._connection.kind === 'ready' && this._connection.client !== client) { + if (this._connection.kind !== 'ready' || this._connection.client !== client) { return; } if (this._mcpServerUrlForName(threadId, name) !== url) { @@ -6670,23 +7493,35 @@ export class CodexAgent extends Disposable implements IAgent { * MCP tool calls (`mcpServer/tool/call`) are thread-scoped, so a call * arriving before the first turn lazily starts the thread. */ - private async _ensureThreadId(session: ICodexSession): Promise { + private async _ensureMaterializedThreadConnection(session: ICodexSession): Promise<{ readonly threadId: string; readonly connection: IConnectionReady }> { await this._materializeIfNeeded(session, session.configurationResource, false); if (session.threadId === undefined) { throw new Error(`Cannot run MCP tool: codex session ${session.sessionId} is not materialized`); } - return session.threadId; + return this._ensureThreadConnection(session); } private _clearRuntimeState(): void { for (const s of this._sessions.values()) { + s.disposed = true; + if (s.prewarmTimer) { + clearTimeout(s.prewarmTimer); + s.prewarmTimer = undefined; + } s.pendingCommandApprovals.denyAll('decline'); s.pendingClientToolCalls.rejectAll(new CancellationError()); s.pendingUserInputs.rejectAll(new CancellationError()); s.mcpController?.dispose(); } for (const subagent of this._subagentsByThreadId.values()) { + subagent.session.disposed = true; + if (subagent.session.prewarmTimer) { + clearTimeout(subagent.session.prewarmTimer); + subagent.session.prewarmTimer = undefined; + } subagent.session.pendingCommandApprovals.denyAll('decline'); + subagent.session.pendingClientToolCalls.rejectAll(new CancellationError()); + subagent.session.pendingUserInputs.rejectAll(new CancellationError()); } for (const entry of this._sessionMcpDiscoveries.values()) { entry.dispose(); @@ -6710,13 +7545,24 @@ export class CodexAgent extends Disposable implements IAgent { this._mcpAuthServerUrlsByResource.clear(); } - async shutdown(): Promise { + private _stopRuntime(): void { + if (this._isShuttingDown) { + return; + } + this._isShuttingDown = true; this._modelCatalogGeneration++; this._modelRefreshRetry.clear(); + this._skillHookCustomizationRefresh.clear(); + this._startupAccountProbeCancellation.dispose(true); + this._disposeTransientAccountConnection(); this._disposeConnection(); this._clearRuntimeState(); } + async shutdown(): Promise { + this._stopRuntime(); + } + resolveChatConfig(params: IAgentResolveChatConfigParams): Promise { const values = codexSessionConfigSchema.validateOrDefault(params.config, codexSessionConfigDefaults); const schema = codexVisibleSessionConfigSchema.toProtocol(); @@ -6806,8 +7652,7 @@ export class CodexAgent extends Disposable implements IAgent { } override dispose(): void { - this._disposeConnection(); - this._clearRuntimeState(); + this._stopRuntime(); super.dispose(); } } diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index ff9fa7ffe8a..0f22875f574 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -27,7 +27,7 @@ import { hasKey } from '../../../../base/common/types.js'; import { NullLogService } from '../../../log/common/log.js'; import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; -import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, SubagentChatSignal, resolveAgentChatContext, type IAgent, type IAgentChatAdoptionResult, type IAgentChatContext, type IAgentChatDataChange, type IAgentChatMetadata, type IAgentChats, type IAgentCreateChatForkSource, type IAgentCreateChatOptions, type IAgentCreateChatResult, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentDescriptor, type IAgentDiscoveredChat, type IAgentLegacyChat, type IAgentMaterializeChatEvent, type IAgentSessionMetadata, type IAgentSpawnChatEvent } from '../../common/agent.js'; +import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, SubagentChatSignal, resolveAgentChatContext, type IAgent, type IAgentChatAdoptionResult, type IAgentChatContext, type IAgentChatDataChange, type IAgentChatMetadata, type IAgentChatMetadataOptions, type IAgentChats, type IAgentCreateChatForkSource, type IAgentCreateChatOptions, type IAgentCreateChatResult, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentDescriptor, type IAgentDiscoveredChat, type IAgentLegacyChat, type IAgentMaterializeChatEvent, type IAgentSessionMetadata, type IAgentSpawnChatEvent } from '../../common/agent.js'; import { IConnectionTrackerService } from '../../common/agentService.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostShowExternalSessionsConfigKey } from '../../common/agentHostSchema.js'; @@ -251,19 +251,20 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { this._remainingRegistryWriteFailures = count; } - async registerSession(session: string, sessionOptions: { provider: string; startTime: number; source: 'explicit' | 'restore' | 'discovery' }, registerOptions: { checkTombstone: boolean }): Promise { + async registerSession(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise { this._beforeWrite(); if (registerOptions.checkTombstone && this._tombstones.has(session)) { return false; } - const { provider, startTime, source } = sessionOptions; + const { provider, startTime, modifiedTime = startTime, source } = sessionOptions; const existing = this._sessions.get(session); - const inserted = { session, provider, startTime, external: source === 'discovery', source }; - this._sessions.set(session, source === 'explicit' + const inserted = { session, provider, startTime, modifiedTime, external: source === 'discovery', source }; + const next: IAgentHostDatabaseSession = source === 'explicit' ? { ...inserted, startTime: existing?.startTime ?? startTime } : existing && source === 'discovery' ? { ...existing, external: true, source: 'discovery' } - : existing ?? inserted); + : existing ?? inserted; + this._sessions.set(session, { ...next, modifiedTime: Math.max(existing?.modifiedTime ?? modifiedTime, modifiedTime) }); if (!registerOptions.checkTombstone) { this._tombstones.delete(session); } @@ -297,6 +298,16 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { } } + async updateSessionModifiedTime(session: string, modifiedTime: number): Promise { + this._beforeWrite(); + const existing = this._sessions.get(session); + if (!existing || existing.modifiedTime >= modifiedTime) { + return false; + } + this._sessions.set(session, { ...existing, modifiedTime }); + return true; + } + async listSessions(): Promise { this.undefinedExternalListCalls++; return [...this._sessions.values()].map(session => this._sessionsWithoutExternal.has(session.session) @@ -381,9 +392,11 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { if (registerOptions.checkTombstone && this._tombstones.has(session)) { return false; } - const { provider, startTime, source } = sessionOptions; + const { provider, startTime, modifiedTime = startTime, source } = sessionOptions; const existing = this._sessions.get(session); - this._sessions.set(session, existing ?? { session, provider, startTime, external: source === 'discovery', source }); + this._sessions.set(session, existing + ? { ...existing, modifiedTime: Math.max(existing.modifiedTime, modifiedTime) } + : { session, provider, startTime, modifiedTime, external: source === 'discovery', source }); if (!registerOptions.checkTombstone) { this._tombstones.delete(session); } @@ -403,6 +416,15 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { async updateSessionExternal(): Promise { } + async updateSessionModifiedTime(session: string, modifiedTime: number): Promise { + const existing = this._sessions.get(session); + if (!existing || existing.modifiedTime >= modifiedTime) { + return false; + } + this._sessions.set(session, { ...existing, modifiedTime }); + return true; + } + async listSessions(): Promise { return [...this._sessions.values()]; } @@ -2991,7 +3013,7 @@ suite('AgentService (node dispatcher)', () => { })), [{ session: external.toString(), external: true, source: 'discovery' }]); }); - test('rediscovery does not overwrite durable unread state for an existing external session', async () => { + test('rediscovery advances recency without overwriting durable unread state for an existing external session', async () => { const db = new TestSessionDatabase(); const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MockAgent('copilot')); @@ -3000,10 +3022,18 @@ suite('AgentService (node dispatcher)', () => { svc.registerProvider(agent); await svc.listSessions(); await db.setMetadata(AH_META_IS_READ_DB_KEY, ''); + const rediscoveredModifiedTime = Date.now() + 60_000; - await (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats(agent, [discoveredChat(session)]); + await (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats(agent, [discoveredChat(session, true, rediscoveredModifiedTime)]); + const registered = await (svc as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry.get(session); - assert.strictEqual(await db.getMetadata(AH_META_IS_READ_DB_KEY), ''); + assert.deepStrictEqual({ + isRead: await db.getMetadata(AH_META_IS_READ_DB_KEY), + modifiedTime: registered?.modifiedTime, + }, { + isRead: '', + modifiedTime: rediscoveredModifiedTime, + }); }); testWithExternalSessionClock('discovery does not ingest external sessions older than 30 days', async () => { @@ -3146,6 +3176,7 @@ suite('AgentService (node dispatcher)', () => { session: AgentSession.uri('copilot', id), provider: 'copilot', startTime, + modifiedTime: startTime, external: false, source: 'restore', }); @@ -3224,6 +3255,7 @@ suite('AgentService (node dispatcher)', () => { session: AgentSession.uri('copilot', `local-${index}`), provider: 'copilot', startTime, + modifiedTime: startTime, external: false, source: 'restore', })); @@ -3263,7 +3295,7 @@ suite('AgentService (node dispatcher)', () => { const initial = recentIds(); for (const id of ['local-first', 'local-second']) { - locals.push({ session: AgentSession.uri('copilot', id), provider: 'copilot', startTime: at(17), external: false, source: 'restore' }); + locals.push({ session: AgentSession.uri('copilot', id), provider: 'copilot', startTime: at(17), modifiedTime: at(17), external: false, source: 'restore' }); } const afterLocalSessionsCreated = recentIds(); // Invalidation is synchronous; read before the queued reconciliation re-snapshots. @@ -4068,8 +4100,8 @@ suite('AgentService (node dispatcher)', () => { const internal = AgentSession.uri('copilot', 'legacy-internal'); const external = AgentSession.uri('claude', 'legacy-external'); const database = new TransientRegistryWriteDatabase(); - database.addSessionWithoutExternal({ session: internal.toString(), provider: 'copilot', startTime: 1, external: false, source: 'explicit' }); - database.addSessionWithoutExternal({ session: external.toString(), provider: 'claude', startTime: 2, external: false, source: 'explicit' }); + database.addSessionWithoutExternal({ session: internal.toString(), provider: 'copilot', startTime: 1, modifiedTime: 1, external: false, source: 'explicit' }); + database.addSessionWithoutExternal({ session: external.toString(), provider: 'claude', startTime: 2, modifiedTime: 2, external: false, source: 'explicit' }); const sessionData = createPerSessionDataService(); await sessionData.database(internal).setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'false'); const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, database)); @@ -4118,6 +4150,7 @@ suite('AgentService (node dispatcher)', () => { database = new AgentHostDatabase(path); const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, database)); const agent = disposables.add(new MockAgent('copilot')); + agent.sessionMetadataOverrides = { startTime: 1, modifiedTime: 1 }; const session = AgentSession.uri('copilot', 'legacy-real-database'); (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(session), session); svc.registerProvider(agent); @@ -4125,7 +4158,12 @@ suite('AgentService (node dispatcher)', () => { await svc.restoreSession(session); const entries = await (svc as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry.list(); - assert.deepStrictEqual(entries.map(entry => ({ external: entry.external, source: entry.source })), [{ external: true, source: 'discovery' }]); + assert.deepStrictEqual(entries.map(entry => ({ + startTime: entry.startTime, + modifiedTime: entry.modifiedTime, + external: entry.external, + source: entry.source, + })), [{ startTime: 1, modifiedTime: 1, external: true, source: 'discovery' }]); } finally { if (legacyDatabase) { await new Promise(resolve => legacyDatabase!.close(() => resolve())); @@ -5007,6 +5045,118 @@ suite('AgentService (node dispatcher)', () => { assert.ok((await svc.listSessions()).some(s => s.session.toString() === session.toString())); }); + test('listSessions preserves the last live modified time when a lazy provider becomes inactive', async () => { + class InactiveMetadataAgent extends MockAgent { + metadataAvailable = true; + override async getChatMetadata(chat: URI, context: URI | IAgentChatContext, _providerData?: string, options?: IAgentChatMetadataOptions): Promise { + return this.metadataAvailable + ? super.getChatMetadata(chat, context) + : options?.registryFallback ? { chat, ...options.registryFallback } : undefined; + } + } + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new InactiveMetadataAgent('copilot')); + svc.registerProvider(agent); + + const session = await svc.createSession({ provider: 'copilot' }); + const registered = (await svc.listSessions()).find(candidate => candidate.session.toString() === session.toString()); + assert.ok(registered); + const modifiedTime = Date.now() + 60_000; + const modifiedAt = new Date(modifiedTime).toISOString(); + const chat = URI.parse(buildDefaultChatUri(session)); + const summaryChanged = Event.toPromise(Event.filter( + getStateManager(svc).onDidChangeSessionSummary, + event => event.session === session.toString() && event.changes.modifiedAt === modifiedAt, + )); + getStateManager(svc).dispatchServerAction(chat.toString(), { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: modifiedAt, + message: { text: 'hello', origin: { kind: MessageKind.User } }, + }); + await summaryChanged; + await (svc as unknown as { _sessionModifiedTimeWrites: Promise })._sessionModifiedTimeWrites; + + // Model a lazy provider before explicit activation: no live state and no + // provider round-trip. The registry keeps the last live timestamp until + // opening the session activates authoritative metadata reads again. + getStateManager(svc).deleteSession(session.toString()); + agent.metadataAvailable = false; + const fallback = (await svc.listSessions()).find(candidate => candidate.session.toString() === session.toString()); + const fallbackAgain = (await svc.listSessions()).find(candidate => candidate.session.toString() === session.toString()); + + assert.ok(fallback); + assert.deepStrictEqual({ + session: fallback.session, + startTime: fallback.startTime, + modifiedTime: fallback.modifiedTime, + repeatedStartTime: fallbackAgain?.startTime, + repeatedModifiedTime: fallbackAgain?.modifiedTime, + }, { + session, + startTime: fallback.startTime, + modifiedTime, + repeatedStartTime: fallback.startTime, + repeatedModifiedTime: modifiedTime, + }); + }); + + testWithExternalSessionClock('lazy provider fallback filters and sorts external sessions by their last modified time', async () => { + class InactiveMetadataAgent extends MockAgent { + override async getChatMetadata(chat: URI, _context: URI | IAgentChatContext, _providerData?: string, options?: IAgentChatMetadataOptions): Promise { + return options?.registryFallback ? { chat, ...options.registryFallback } : undefined; + } + } + const svc = createExternalSessionService(); + const agent = disposables.add(new InactiveMetadataAgent('copilot')); + svc.registerProvider(agent); + const registry = (svc as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry; + const now = Date.now(); + const day = 24 * 60 * 60 * 1000; + const oldRecentlyUsed = AgentSession.uri('copilot', 'old-recently-used'); + const newLessRecentlyUsed = AgentSession.uri('copilot', 'new-less-recently-used'); + await registry.register(oldRecentlyUsed, { + provider: 'copilot', + startTime: now - 10 * day, + modifiedTime: now - 5 * 60 * 1000, + source: 'discovery', + } as IAgentHostDatabaseSessionOptions, { checkTombstone: true }); + await registry.register(newLessRecentlyUsed, { + provider: 'copilot', + startTime: now - 30 * 60 * 1000, + modifiedTime: now - 30 * 60 * 1000, + source: 'discovery', + } as IAgentHostDatabaseSessionOptions, { checkTombstone: true }); + + const listed = await svc.listSessions(AgentHostExternalSessionsMode.Last24Hours); + + assert.deepStrictEqual(listed.map(session => ({ + id: AgentSession.id(session.session), + modifiedTime: session.modifiedTime, + })), [ + { id: 'old-recently-used', modifiedTime: now - 5 * 60 * 1000 }, + { id: 'new-less-recently-used', modifiedTime: now - 30 * 60 * 1000 }, + ]); + }); + + test('listSessions does not synthesize registry metadata for other providers', async () => { + class MissingMetadataAgent extends MockAgent { + metadataAvailable = true; + override async getChatMetadata(chat: URI, context: URI | IAgentChatContext): Promise { + return this.metadataAvailable ? super.getChatMetadata(chat, context) : undefined; + } + } + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MissingMetadataAgent('copilot')); + svc.registerProvider(agent); + + const session = await svc.createSession({ provider: 'copilot' }); + getStateManager(svc).deleteSession(session.toString()); + agent.metadataAvailable = false; + + assert.strictEqual((await svc.listSessions()).some(candidate => candidate.session.toString() === session.toString()), false); + }); + test('session registry stays in parity with listSessions across create/delete', async () => { const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = new MockAgent('copilot'); @@ -6754,6 +6904,43 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual(await db.getChatDraft(chat), expected); } + test('marks only an explicit restore as an activating metadata read', async () => { + class LazyMetadataAgent extends MockAgent { + ambientReads = 0; + restoreReads = 0; + + override async getChatMetadata(chat: URI, context: URI | IAgentChatContext, _providerData?: string, options?: IAgentChatMetadataOptions): Promise { + if (options?.activation === 'restore') { + this.restoreReads++; + } else { + this.ambientReads++; + } + return super.getChatMetadata(chat, context); + } + } + + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new LazyMetadataAgent('codex')); + svc.registerProvider(agent); + const session = await svc.createSession({ provider: agent.id }); + await svc.listSessions(); + agent.ambientReads = 0; + agent.restoreReads = 0; + getStateManager(svc).deleteSession(session.toString()); + + await svc.listSessions(); + const readsAfterAmbientListing = { ambient: agent.ambientReads, restore: agent.restoreReads }; + await svc.restoreSession(session); + + assert.deepStrictEqual({ + readsAfterAmbientListing, + readsAfterRestore: { ambient: agent.ambientReads, restore: agent.restoreReads }, + }, { + readsAfterAmbientListing: { ambient: 1, restore: 0 }, + readsAfterRestore: { ambient: 1, restore: 1 }, + }); + }); + test('waits for initial provider migration before restoring a session', async () => { class DelayedMigrationAgent extends MockAgent { readonly migrationGate = new DeferredPromise(); diff --git a/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts index f63f03303a2..8c903898511 100644 --- a/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts @@ -34,14 +34,15 @@ class TestAgentHostDatabase implements IAgentHostDatabase { if (registerOptions.checkTombstone && this._tombstones.has(session)) { return false; } - const { provider, startTime, source } = sessionOptions; + const { provider, startTime, modifiedTime = startTime, source } = sessionOptions; const existing = this.sessions.get(session); - const inserted = { session, provider, startTime, external: source === 'discovery', source }; - this.sessions.set(session, source === 'explicit' + const inserted = { session, provider, startTime, modifiedTime, external: source === 'discovery', source }; + const next: IAgentHostDatabaseSession = source === 'explicit' ? { ...inserted, startTime: existing?.startTime ?? startTime } : existing && source === 'discovery' ? { ...existing, external: true, source: 'discovery' } - : existing ?? inserted); + : existing ?? inserted; + this.sessions.set(session, { ...next, modifiedTime: Math.max(existing?.modifiedTime ?? modifiedTime, modifiedTime) }); if (!registerOptions.checkTombstone) { this._tombstones.delete(session); } @@ -73,6 +74,16 @@ class TestAgentHostDatabase implements IAgentHostDatabase { } } + async updateSessionModifiedTime(session: string, modifiedTime: number): Promise { + this._throwWriteFailure(); + const existing = this.sessions.get(session); + if (!existing || existing.modifiedTime >= modifiedTime) { + return false; + } + this.sessions.set(session, { ...existing, modifiedTime }); + return true; + } + async listSessions(): Promise { this._throwReadFailure(); this.listCalls++; @@ -186,7 +197,7 @@ suite('AgentSessionRegistry', () => { test('listSessionKeys does not migrate legacy entries', async () => { const testDatabase = new TestAgentHostDatabase(); database = testDatabase; - testDatabase.sessions.set(a.toString(), { session: a.toString(), provider: 'copilot', startTime: 1, external: undefined, source: 'explicit' }); + testDatabase.sessions.set(a.toString(), { session: a.toString(), provider: 'copilot', startTime: 1, modifiedTime: 1, external: undefined, source: 'explicit' }); const registry = createRegistry(); assert.deepStrictEqual({ @@ -203,8 +214,8 @@ suite('AgentSessionRegistry', () => { test('list migrates entries and returns the computed list without rereading', async () => { const testDatabase = new TestAgentHostDatabase(); database = testDatabase; - testDatabase.sessions.set(a.toString(), { session: a.toString(), provider: 'copilot', startTime: 1, external: false, source: 'explicit' }); - testDatabase.sessions.set(b.toString(), { session: b.toString(), provider: 'claude', startTime: 2, external: undefined, source: 'explicit' }); + testDatabase.sessions.set(a.toString(), { session: a.toString(), provider: 'copilot', startTime: 1, modifiedTime: 1, external: false, source: 'explicit' }); + testDatabase.sessions.set(b.toString(), { session: b.toString(), provider: 'claude', startTime: 2, modifiedTime: 2, external: undefined, source: 'explicit' }); const registry = createRegistry(); const migratedEntries: string[] = []; @@ -236,8 +247,8 @@ suite('AgentSessionRegistry', () => { test('get reads only the requested session', async () => { const testDatabase = new TestAgentHostDatabase(); database = testDatabase; - testDatabase.sessions.set(a.toString(), { session: a.toString(), provider: 'copilot', startTime: 1, external: false, source: 'explicit' }); - testDatabase.sessions.set(b.toString(), { session: b.toString(), provider: 'claude', startTime: 2, external: false, source: 'explicit' }); + testDatabase.sessions.set(a.toString(), { session: a.toString(), provider: 'copilot', startTime: 1, modifiedTime: 1, external: false, source: 'explicit' }); + testDatabase.sessions.set(b.toString(), { session: b.toString(), provider: 'claude', startTime: 2, modifiedTime: 2, external: false, source: 'explicit' }); const registry = createRegistry(); const [entry, missing] = await Promise.all([ @@ -270,10 +281,10 @@ suite('AgentSessionRegistry', () => { assert.strictEqual(await registry.isEmpty(), false); assert.deepStrictEqual( - (await list(registry)).map(s => ({ session: s.session.toString(), provider: s.provider, startTime: s.startTime, external: s.external })).sort((x, y) => x.session.localeCompare(y.session)), + (await list(registry)).map(s => ({ session: s.session.toString(), provider: s.provider, startTime: s.startTime, modifiedTime: s.modifiedTime, external: s.external })).sort((x, y) => x.session.localeCompare(y.session)), [ - { session: b.toString(), provider: 'claude', startTime: 200, external: false }, - { session: a.toString(), provider: 'copilot', startTime: 100, external: false }, + { session: b.toString(), provider: 'claude', startTime: 200, modifiedTime: 200, external: false }, + { session: a.toString(), provider: 'copilot', startTime: 100, modifiedTime: 100, external: false }, ].sort((x, y) => x.session.localeCompare(y.session)), ); @@ -281,13 +292,15 @@ suite('AgentSessionRegistry', () => { assert.deepStrictEqual((await list(registry)).map(s => s.session.toString()), [b.toString()]); }); - test('register preserves the first-observed startTime', async () => { + test('register preserves startTime and advances modifiedTime monotonically', async () => { const registry = createRegistry(); - await registerExplicit(registry, a, 'copilot', 100); - await registerExplicit(registry, a, 'copilot', 999); + await registry.register(a, { provider: 'copilot', startTime: 100, modifiedTime: 150, source: 'explicit' }, { checkTombstone: false }); + await registry.register(a, { provider: 'copilot', startTime: 999, modifiedTime: 120, source: 'explicit' }, { checkTombstone: false }); + await registry.updateModifiedTime(a, 175); + await registry.updateModifiedTime(a, 160); const [entry] = await list(registry); - assert.strictEqual(entry.startTime, 100); + assert.deepStrictEqual({ startTime: entry.startTime, modifiedTime: entry.modifiedTime }, { startTime: 100, modifiedTime: 175 }); }); test('register and tombstone preserve submission order', async () => { diff --git a/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts b/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts index ad659822863..d3010679a0a 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts @@ -225,7 +225,7 @@ suite('CodexAgent', () => { }); }); - test('prefers transient host context over conversation URI shape', () => { + test('does not treat a transient host configuration scope as a chat backing', () => { const session = AgentSession.uri('codex', 'session-1'); const result = resolveConversationSession(emptyHarness(), URI.parse('untitled:conversation'), { @@ -233,7 +233,7 @@ suite('CodexAgent', () => { configurationResource: session, }); - assert.strictEqual(result?.toString(), session.toString()); + assert.strictEqual(result, undefined); }); test('resolves a bound conversation URI from the recorded session binding', () => { @@ -249,7 +249,7 @@ suite('CodexAgent', () => { assert.strictEqual(result?.toString(), session.toString()); }); - test('resolution has exactly two sources: a recorded binding or host context', () => { + test('resolution uses only a recorded binding', () => { const session = AgentSession.uri('codex', 'session-3'); const defaultChat = URI.parse(buildDefaultChatUri(session)); @@ -257,15 +257,16 @@ suite('CodexAgent', () => { // The legacy "a codex session URI addresses its own chat" adapter is // gone: an unbound session URI is not self-resolving any more. unboundSessionUri: resolveConversationSession(emptyHarness(), session)?.toString(), - // Nor is a chat URI recognized by shape — an unbound default chat - // only resolves once the host supplies its owning session. + // Nor is a chat URI recognized by shape or by the configuration scope + // supplied in host context. That scope does not identify a peer's + // independent backing thread. unboundDefaultChat: resolveConversationSession(emptyHarness(), defaultChat)?.toString(), withHostContext: resolveConversationSession(emptyHarness(), defaultChat, { configurationResource: session, resource: defaultChat })?.toString(), foreignUri: resolveConversationSession(emptyHarness(), URI.parse('untitled:unknown'))?.toString(), }, { unboundSessionUri: undefined, unboundDefaultChat: undefined, - withHostContext: session.toString(), + withHostContext: undefined, foreignUri: undefined, }); }); @@ -384,6 +385,9 @@ suite('CodexAgent', () => { const discoveredChats: number[] = []; const listener = onDidDiscoverChats.event(chats => discoveredChats.push(chats.length)); type DiscoveryHarness = { + _activated: boolean; + _isShuttingDown: boolean; + _store: { isDisposed: boolean }; _codexChatDiscovery: Promise | undefined; _isSdkResolvableWithoutDownload(): Promise; _emitCodexChats(): Promise; @@ -396,6 +400,9 @@ suite('CodexAgent', () => { }; let sdkIsLocal = false; const harness: DiscoveryHarness = { + _activated: true, + _isShuttingDown: false, + _store: { isDisposed: false }, _logService: { warn: () => { }, info: () => { } }, _codexChatDiscovery: undefined, _isSdkResolvableWithoutDownload: async () => sdkIsLocal, @@ -435,6 +442,7 @@ suite('CodexAgent', () => { ]; const listChatsToMigrate = (CodexAgent.prototype as unknown as { listChatsToMigrate(this: { + _activated: boolean; _isSdkResolvableWithoutDownload(): Promise; _listCodexChats(): Promise; _isKnownCodexChat(chat: (typeof chats)[number]): Promise; @@ -445,6 +453,7 @@ suite('CodexAgent', () => { // and fetching it is the user's call. let sdkIsLocal = false; const harness = { + _activated: true, _logService: { info: () => { } }, _isSdkResolvableWithoutDownload: async () => sdkIsLocal, _listCodexChats: async () => chats, @@ -454,15 +463,16 @@ suite('CodexAgent', () => { }, }; + const inactive = await listChatsToMigrate.call({ ...harness, _activated: false }); const cold = await listChatsToMigrate.call(harness); sdkIsLocal = true; const result = await listChatsToMigrate.call(harness); const empty = await listChatsToMigrate.call({ ...harness, _listCodexChats: async () => [], _isKnownCodexChat: async () => false }); - assert.deepStrictEqual({ cold, result, empty }, { cold: undefined, result: chats.slice(0, 2), empty: [] }); + assert.deepStrictEqual({ inactive, cold, result, empty }, { inactive: [], cold: undefined, result: chats.slice(0, 2), empty: [] }); }); - test('native discovery emits only unknown Codex chats as external', async () => { + test('activated discovery classifies known Codex chats as internal and unknown chats as external', async () => { const knownInternal = AgentSession.uri('codex', 'known-internal'); const knownExternal = AgentSession.uri('codex', 'known-external'); const unknownExternal = AgentSession.uri('codex', 'unknown-external'); @@ -474,14 +484,18 @@ suite('CodexAgent', () => { const emitted: unknown[] = []; const emitCodexChats = (CodexAgent.prototype as unknown as { _emitCodexChats(this: { + _isShuttingDown: boolean; + _store: { isDisposed: boolean }; _listCodexChats(): Promise; _isKnownCodexChat(chat: (typeof chats)[number]): Promise; _onDidDiscoverChats: { fire(chats: readonly unknown[]): void }; _logService: { warn(message: string): void }; - }): Promise; + }): Promise; })._emitCodexChats; await emitCodexChats.call({ + _isShuttingDown: false, + _store: { isDisposed: false }, _listCodexChats: async () => chats, _isKnownCodexChat: async chat => { const id = AgentSession.id(URI.parse(parseRequiredSessionUriFromChatUri(chat.chat))); @@ -491,6 +505,10 @@ suite('CodexAgent', () => { _logService: { warn: () => { } }, }); - assert.deepStrictEqual(emitted, [{ ...chats[2], external: true }]); + assert.deepStrictEqual(emitted, [ + { ...chats[0], external: false }, + { ...chats[1], external: false }, + { ...chats[2], external: true }, + ]); }); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts index cb8064368ae..83bf5e984e5 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts @@ -6,6 +6,7 @@ import type { CCAModel } from '@vscode/copilot-api'; import assert from 'assert'; import { PassThrough } from 'stream'; +import { DeferredPromise } from '../../../../../base/common/async.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; @@ -51,6 +52,7 @@ interface ITestWireRequest { readonly params: { readonly cwd?: string; readonly threadId?: string; + readonly includeTurns?: boolean; readonly numTurns?: number; readonly input?: readonly { readonly type: string; readonly text?: string; readonly text_elements?: readonly object[] }[]; readonly additionalContext?: Readonly>; @@ -212,6 +214,8 @@ async function createAgent(disposables: Pick, options: I instantiationService.stub(IFileService, fileService); instantiationService.stub(ILogService, logService); const agent = disposables.add(instantiationService.createInstance(CodexAgent)); + agent['_probeAccountAtStartup'] = async () => { }; + agent['_activated'] = true; agent['_refreshSkillHookCustomizations'] = async () => { }; agent['_refreshSkillExtraRoots'] = async () => { }; await agent.authenticate(agent.getProtectedResources()[0].resource, 'test-token'); @@ -410,6 +414,159 @@ suite('CodexAgent createChat', () => { }); }); + test('concurrent creates for the same chat share the first backing', async () => { + const agent = await createAgent(disposables); + const sessionUri = AgentSession.uri('codex', 'session-concurrent-create'); + const chat = URI.parse(buildDefaultChatUri(sessionUri)); + const folder = URI.file('/repo/concurrent-create'); + const catalog = agent['_models'].get(); + agent['_models'].set([], undefined); + + const refreshStarted = new DeferredPromise(); + const releaseRefresh = new DeferredPromise(); + const originalRefreshModels = agent.refreshModels.bind(agent); + const originalStartChatBacking = agent['_startChatBacking'].bind(agent); + agent.refreshModels = async () => { + await refreshStarted.complete(undefined); + await releaseRefresh.p; + agent['_models'].set(catalog, undefined); + }; + agent['_startChatBacking'] = async () => { + throw new Error('duplicate create tried to mint another backing'); + }; + + try { + const first = agent.chats.createChat(chat, { configurationResource: sessionUri, resource: chat }, { + workingDirectories: [folder], + }); + const second = agent.chats.createChat(chat, { configurationResource: sessionUri, resource: chat }, { + workingDirectories: [folder], + }); + await refreshStarted.p; + await releaseRefresh.complete(undefined); + + const results = await Promise.all([first, second]); + assert.deepStrictEqual({ + sessionCount: agent['_sessions'].size, + boundSessionId: agent['_sessionIdByChatUri'].get(chat.toString()), + providerData: results.map(result => result?.providerData && JSON.parse(result.providerData)), + }, { + sessionCount: 1, + boundSessionId: AgentSession.id(sessionUri), + providerData: [{ sessionId: AgentSession.id(sessionUri), model: { id: COPILOT_TEST_MODEL } }, { sessionId: AgentSession.id(sessionUri), model: { id: COPILOT_TEST_MODEL } }], + }); + } finally { + agent.refreshModels = originalRefreshModels; + agent['_startChatBacking'] = originalStartChatBacking; + } + }); + + test('dispose waits for an in-flight create of the same chat', async () => { + const agent = await createAgent(disposables); + const sessionUri = AgentSession.uri('codex', 'session-create-dispose-race'); + const chat = URI.parse(buildDefaultChatUri(sessionUri)); + const catalog = agent['_models'].get(); + agent['_models'].set([], undefined); + const refreshStarted = new DeferredPromise(); + const releaseRefresh = new DeferredPromise(); + const originalRefreshModels = agent.refreshModels.bind(agent); + agent.refreshModels = async () => { + await refreshStarted.complete(undefined); + await releaseRefresh.p; + agent['_models'].set(catalog, undefined); + }; + + try { + const create = agent.chats.createChat(chat, { configurationResource: sessionUri, resource: chat }, { + workingDirectories: [URI.file('/repo/create-dispose-race')], + }); + await refreshStarted.p; + const dispose = agent.chats.disposeChat(chat, { configurationResource: sessionUri, resource: chat }); + await releaseRefresh.complete(undefined); + await Promise.all([create, dispose]); + + assert.deepStrictEqual({ + sessionCount: agent['_sessions'].size, + boundSessionId: agent['_sessionIdByChatUri'].get(chat.toString()), + trackedScope: agent['_configScopeByChat'].get(chat.toString()), + }, { + sessionCount: 0, + boundSessionId: undefined, + trackedScope: undefined, + }); + } finally { + agent.refreshModels = originalRefreshModels; + } + }); + + test('rebind moves a chat between configuration scopes without leaking the old scope', async () => { + const agent = await createAgent(disposables); + const originalScope = AgentSession.uri('codex', 'scope-original'); + const replacementScope = AgentSession.uri('codex', 'scope-replacement'); + const chat = URI.parse(buildDefaultChatUri(originalScope)); + const folder = URI.file('/repo/rebind-scope'); + const advertised: string[] = []; + agent.setServerToolHost(createRecordingServerToolHost(advertised)); + + await createSessionBackedChat(agent, chat, { configurationResource: originalScope, resource: chat }, { + workingDirectories: [folder], + }); + await agent.chats.createChat(chat, { configurationResource: replacementScope, resource: chat }, { + workingDirectories: [folder], + }); + await agent.chats.disposeChat(chat, { configurationResource: replacementScope, resource: chat }); + + assert.deepStrictEqual({ + trackedScopes: [...agent['_configScopeChats'].keys()], + trackedChatScope: agent['_configScopeByChat'].get(chat.toString()), + advertised, + }, { + trackedScopes: [], + trackedChatScope: undefined, + advertised: [originalScope.toString(), replacementScope.toString()], + }); + }); + + test('a failed rebind preserves the existing runtime and configuration scope', async () => { + const agent = await createAgent(disposables); + const originalScope = AgentSession.uri('codex', 'rebind-failure-original'); + const replacementScope = AgentSession.uri('codex', 'rebind-failure-replacement'); + const chat = URI.parse(buildDefaultChatUri(originalScope)); + const folder = URI.file('/repo/rebind-failure'); + await createSessionBackedChat(agent, chat, { configurationResource: originalScope, resource: chat }, { + workingDirectories: [folder], + }); + const entry = agent['_sessions'].get(AgentSession.id(originalScope))!; + // Make the requested model a real provisional change. The test catalog's + // only model is also the default selected by the initial creation. + entry.model = undefined; + const originalSync = agent['_syncClientCustomizations'].bind(agent); + agent['_syncClientCustomizations'] = async () => { throw new Error('rebind sync failed'); }; + try { + await assert.rejects(agent.chats.createChat(chat, { configurationResource: replacementScope, resource: chat }, { + workingDirectories: [folder], + model: { id: COPILOT_TEST_MODEL }, + activeClient: { clientId: 'rebind-client', tools: [], customizations: [] }, + }), /rebind sync failed/); + } finally { + agent['_syncClientCustomizations'] = originalSync; + } + + assert.deepStrictEqual({ + model: entry.model, + configurationResource: entry.configurationResource.toString(), + trackedScope: agent['_configScopeByChat'].get(chat.toString()), + boundSession: agent['_sessionIdByChatUri'].get(chat.toString()), + hasFailedHandle: agent['_activeClientHandles'].has(`${chat.toString()}\u0000rebind-client`), + }, { + model: undefined, + configurationResource: originalScope.toString(), + trackedScope: originalScope.toString(), + boundSession: AgentSession.id(originalScope), + hasFailedHandle: false, + }); + }); + test('importConversation: explicitly rejects instead of silently creating an empty fresh session', async () => { const agent = await createAgent(disposables); const sessionUri = AgentSession.uri('codex', 'session-import'); @@ -540,6 +697,142 @@ suite('CodexAgent createChat', () => { } }); + test('failed eager chat creation archives the thread it minted before releasing it', async () => { + const agent = await createAgent(disposables, { sdkResolvableWithoutDownload: true }); + const peer = disposables.add(createTestPeer()); + connectPeer(agent, peer); + + try { + const sessionUri = AgentSession.uri('codex', 'session-failed-eager-backing'); + const sessionChat = URI.parse(buildDefaultChatUri(sessionUri)); + const peerChat = URI.parse(buildChatUri(sessionUri, 'failed-eager')); + const folder = URI.file('/repo/failed-eager-backing'); + await createSessionBackedChat(agent, sessionChat, { configurationResource: sessionUri, resource: sessionChat }, { + workingDirectories: [folder], + model: { id: COPILOT_TEST_MODEL }, + }); + const sessionStart = await readNextRequest(peer.outbound); + peer.push({ id: sessionStart.id, result: { thread: { id: 'owning-thread', cwd: folder.fsPath } } }); + await agent['_sessions'].get('session-failed-eager-backing')!.materializePromise; + + const originalSync = agent['_syncClientCustomizations'].bind(agent); + agent['_syncClientCustomizations'] = async () => { throw new Error('eager client sync failed'); }; + try { + const creating = agent.chats.createChat(peerChat, { configurationResource: sessionUri, resource: peerChat }, { + workingDirectories: [folder], + model: { id: COPILOT_TEST_MODEL }, + activeClient: { clientId: 'client-failed-eager', tools: [], customizations: [] }, + }); + const peerStart = await readNextRequest(peer.outbound); + peer.push({ id: peerStart.id, result: { thread: { id: 'orphaned-thread', cwd: folder.fsPath } } }); + + const firstCleanup = await readNextRequest(peer.outbound); + peer.push({ id: firstCleanup.id, result: {} }); + let secondCleanup: ITestWireRequest | undefined; + if (firstCleanup.method === 'thread/archive') { + secondCleanup = await readNextRequest(peer.outbound); + peer.push({ id: secondCleanup.id, result: {} }); + } + await assert.rejects(creating, /eager client sync failed/); + + assert.deepStrictEqual({ + start: { method: peerStart.method, cwd: peerStart.params.cwd }, + firstCleanup: { method: firstCleanup.method, threadId: firstCleanup.params.threadId }, + secondCleanup: secondCleanup && { method: secondCleanup.method, threadId: secondCleanup.params.threadId }, + hasRuntime: agent['_sessions'].has('orphaned-thread'), + hasBinding: agent['_sessionIdByChatUri'].has(peerChat.toString()), + }, { + start: { method: 'thread/start', cwd: folder.fsPath }, + firstCleanup: { method: 'thread/archive', threadId: 'orphaned-thread' }, + secondCleanup: { method: 'thread/unsubscribe', threadId: 'orphaned-thread' }, + hasRuntime: false, + hasBinding: false, + }); + } finally { + agent['_syncClientCustomizations'] = originalSync; + } + } finally { + peer.dispose(); + } + }); + + test('failed eager chat creation archives its minted thread after the app-server connection is replaced', async () => { + const agent = await createAgent(disposables, { sdkResolvableWithoutDownload: true }); + const peer = disposables.add(createTestPeer()); + connectPeer(agent, peer); + + try { + const sessionUri = AgentSession.uri('codex', 'session-failed-eager-reconnect'); + const sessionChat = URI.parse(buildDefaultChatUri(sessionUri)); + const peerChat = URI.parse(buildChatUri(sessionUri, 'failed-eager-reconnect')); + const folder = URI.file('/repo/failed-eager-reconnect'); + await createSessionBackedChat(agent, sessionChat, { configurationResource: sessionUri, resource: sessionChat }, { + workingDirectories: [folder], + model: { id: COPILOT_TEST_MODEL }, + }); + const sessionStart = await readNextRequest(peer.outbound); + peer.push({ id: sessionStart.id, result: { thread: { id: 'owning-reconnect-thread', cwd: folder.fsPath } } }); + await agent['_sessions'].get('session-failed-eager-reconnect')!.materializePromise; + + const replacementRequests: Array<{ readonly method: string; readonly threadId?: string }> = []; + const replacement = { + kind: 'ready', + client: { + request: async (method: string, params: { readonly threadId?: string }) => { + replacementRequests.push({ method, threadId: params.threadId }); + return {}; + }, + }, + proxyHandle: { dispose() { } }, + child: { kill: () => true }, + }; + const originalEnsureConnection = agent['_ensureConnection'].bind(agent); + const originalSync = agent['_syncClientCustomizations'].bind(agent); + agent['_ensureConnection'] = async () => { + if (agent['_connection'].kind === 'idle') { + agent['_connection'] = replacement as never; + return replacement as never; + } + return originalEnsureConnection(); + }; + agent['_syncClientCustomizations'] = async () => { + const lost = agent['_connection']; + assert.strictEqual(lost.kind, 'ready'); + agent['_handleConnectionLost'](lost as never, agent['_connectionGeneration']); + throw new Error('eager client sync failed after disconnect'); + }; + try { + const creating = agent.chats.createChat(peerChat, { configurationResource: sessionUri, resource: peerChat }, { + workingDirectories: [folder], + model: { id: COPILOT_TEST_MODEL }, + activeClient: { clientId: 'client-failed-eager-reconnect', tools: [], customizations: [] }, + }); + const peerStart = await readNextRequest(peer.outbound); + peer.push({ id: peerStart.id, result: { thread: { id: 'orphaned-reconnect-thread', cwd: folder.fsPath } } }); + + await assert.rejects(creating, /eager client sync failed after disconnect/); + + assert.deepStrictEqual({ + replacementRequests, + hasRuntime: agent['_sessions'].has('orphaned-reconnect-thread'), + hasBinding: agent['_sessionIdByChatUri'].has(peerChat.toString()), + }, { + replacementRequests: [ + { method: 'thread/archive', threadId: 'orphaned-reconnect-thread' }, + { method: 'thread/unsubscribe', threadId: 'orphaned-reconnect-thread' }, + ], + hasRuntime: false, + hasBinding: false, + }); + } finally { + agent['_ensureConnection'] = originalEnsureConnection; + agent['_syncClientCustomizations'] = originalSync; + } + } finally { + peer.dispose(); + } + }); + test('fork: preserves the exact source thread and binds the forked session directly to the target chat', async () => { const agent = await createAgent(disposables, { sdkResolvableWithoutDownload: true }); const peer = disposables.add(createTestPeer()); @@ -567,6 +860,7 @@ suite('CodexAgent createChat', () => { const read = await readNextRequest(peer.outbound); assert.strictEqual(read.method, 'thread/read'); assert.strictEqual(read.params.threadId, 'source-thread'); + assert.strictEqual(read.params.includeTurns, true); peer.push({ id: read.id, result: { thread: { id: 'source-thread', cwd: folder.fsPath, turns: [{ id: 'turn-1' }] } }, @@ -632,6 +926,123 @@ suite('CodexAgent createChat', () => { } }); + test('fork resumes a source from a replacement app-server before reading or forking it', async () => { + const agent = await createAgent(disposables); + const peer = disposables.add(createTestPeer()); + connectPeer(agent, peer); + + try { + const sourceSession = AgentSession.uri('codex', 'resume-before-fork-source'); + const sourceChat = URI.parse(buildDefaultChatUri(sourceSession)); + const targetSession = AgentSession.uri('codex', 'resume-before-fork-target'); + const targetChat = URI.parse(buildDefaultChatUri(targetSession)); + const folder = URI.file('/repo/resume-before-fork'); + await createSessionBackedChat(agent, sourceChat, { configurationResource: sourceSession, resource: sourceChat }, { + workingDirectories: [folder], + model: { id: COPILOT_TEST_MODEL }, + }); + const sourceEntry = agent['_sessions'].get(AgentSession.id(sourceSession))!; + sourceEntry.threadId = 'resume-before-fork-thread'; + sourceEntry.needsResume = true; + agent['_sessionIdByThreadId'].set(sourceEntry.threadId, sourceEntry.sessionId); + + const forking = createSessionBackedChat(agent, targetChat, { configurationResource: targetSession, resource: targetChat }, { + fork: { source: sourceChat, turnId: 'source-turn', turnIndex: 0 }, + }); + const resume = await readNextRequest(peer.outbound); + peer.push({ id: resume.id, result: { thread: { id: sourceEntry.threadId, cwd: folder.fsPath }, cwd: folder.fsPath } }); + const resumeInventory = await readNextRequest(peer.outbound); + peer.push({ id: resumeInventory.id, result: { data: [], nextCursor: null } }); + const read = await readNextRequest(peer.outbound); + peer.push({ id: read.id, result: { thread: { id: sourceEntry.threadId, cwd: folder.fsPath, turns: [{ id: 'source-turn' }] } } }); + const fork = await readNextRequest(peer.outbound); + peer.push({ id: fork.id, result: { thread: { id: 'resumed-fork-thread', cwd: folder.fsPath }, cwd: folder.fsPath } }); + await forking; + const forkInventory = await readNextRequest(peer.outbound); + peer.push({ id: forkInventory.id, result: { data: [], nextCursor: null } }); + + assert.deepStrictEqual([ + { method: resume.method, threadId: resume.params.threadId }, + { method: resumeInventory.method, threadId: resumeInventory.params.threadId }, + { method: read.method, threadId: read.params.threadId }, + { method: fork.method, threadId: fork.params.threadId }, + ], [ + { method: 'thread/resume', threadId: 'resume-before-fork-thread' }, + { method: 'mcpServerStatus/list', threadId: 'resume-before-fork-thread' }, + { method: 'thread/read', threadId: 'resume-before-fork-thread' }, + { method: 'thread/fork', threadId: 'resume-before-fork-thread' }, + ]); + } finally { + peer.dispose(); + } + }); + + test('fork resumes again when the app-server is replaced after the source read', async () => { + const agent = await createAgent(disposables); + const firstPeer = disposables.add(createTestPeer()); + const secondPeer = disposables.add(createTestPeer()); + connectPeer(agent, firstPeer); + + try { + const sourceSession = AgentSession.uri('codex', 'replace-after-read-source'); + const sourceChat = URI.parse(buildDefaultChatUri(sourceSession)); + const targetSession = AgentSession.uri('codex', 'replace-after-read-target'); + const targetChat = URI.parse(buildDefaultChatUri(targetSession)); + const folder = URI.file('/repo/replace-after-read'); + await createSessionBackedChat(agent, sourceChat, { configurationResource: sourceSession, resource: sourceChat }, { + workingDirectories: [folder], + model: { id: COPILOT_TEST_MODEL }, + }); + const sourceEntry = agent['_sessions'].get(AgentSession.id(sourceSession))!; + sourceEntry.threadId = 'replace-after-read-thread'; + sourceEntry.needsResume = false; + agent['_sessionIdByThreadId'].set(sourceEntry.threadId, sourceEntry.sessionId); + + const forking = createSessionBackedChat(agent, targetChat, { configurationResource: targetSession, resource: targetChat }, { + fork: { source: sourceChat, turnId: 'source-turn', turnIndex: 0 }, + }); + const read = await readNextRequest(firstPeer.outbound); + assert.strictEqual(read.method, 'thread/read'); + firstPeer.push({ id: read.id, result: { thread: { id: sourceEntry.threadId, cwd: folder.fsPath, turns: [{ id: 'source-turn' }] } } }); + + // Replace the process in the response-to-next-request gap. The fork must + // not be sent to the new process until its source thread is resumed there. + const lostConnection = agent['_connection']; + assert.strictEqual(lostConnection.kind, 'ready'); + agent['_handleConnectionLost'](lostConnection as never, agent['_connectionGeneration']); + connectPeer(agent, secondPeer); + + const resume = await readNextRequest(secondPeer.outbound); + assert.strictEqual(resume.method, 'thread/resume'); + secondPeer.push({ id: resume.id, result: { thread: { id: sourceEntry.threadId, cwd: folder.fsPath }, cwd: folder.fsPath } }); + const resumeInventory = await readNextRequest(secondPeer.outbound); + secondPeer.push({ id: resumeInventory.id, result: { data: [], nextCursor: null } }); + const retriedRead = await readNextRequest(secondPeer.outbound); + assert.strictEqual(retriedRead.method, 'thread/read'); + secondPeer.push({ id: retriedRead.id, result: { thread: { id: sourceEntry.threadId, cwd: folder.fsPath, turns: [{ id: 'source-turn' }] } } }); + const fork = await readNextRequest(secondPeer.outbound); + secondPeer.push({ id: fork.id, result: { thread: { id: 'replace-after-read-fork', cwd: folder.fsPath }, cwd: folder.fsPath } }); + await forking; + const forkInventory = await readNextRequest(secondPeer.outbound); + secondPeer.push({ id: forkInventory.id, result: { data: [], nextCursor: null } }); + + assert.deepStrictEqual([ + { method: read.method, threadId: read.params.threadId }, + { method: resume.method, threadId: resume.params.threadId }, + { method: retriedRead.method, threadId: retriedRead.params.threadId }, + { method: fork.method, threadId: fork.params.threadId }, + ], [ + { method: 'thread/read', threadId: 'replace-after-read-thread' }, + { method: 'thread/resume', threadId: 'replace-after-read-thread' }, + { method: 'thread/read', threadId: 'replace-after-read-thread' }, + { method: 'thread/fork', threadId: 'replace-after-read-thread' }, + ]); + } finally { + firstPeer.dispose(); + secondPeer.dispose(); + } + }); + test('an additional chat mints a backing thread of its own, and re-creating it never mints a second', async () => { const agent = await createAgent(disposables, { sdkResolvableWithoutDownload: true }); const peer = disposables.add(createTestPeer()); @@ -956,6 +1367,28 @@ suite('CodexAgent exact chat routing', () => { } }); + test('disposing an unbound peer chat does not tear down the owning session runtime', async () => { + const agent = await createAgent(disposables); + const sessionUri = AgentSession.uri('codex', 'session-unbound-peer-dispose'); + const sessionChat = URI.parse(buildDefaultChatUri(sessionUri)); + const unboundPeer = URI.parse(buildChatUri(sessionUri, 'never-bound')); + + await createSessionBackedChat(agent, sessionChat, { configurationResource: sessionUri, resource: sessionChat }, { + model: { id: COPILOT_TEST_MODEL }, + }); + assert.strictEqual(agent['_sessionIdByChatUri'].get(sessionChat.toString()), 'session-unbound-peer-dispose'); + + await agent.chats.disposeChat(unboundPeer, { configurationResource: sessionUri, resource: unboundPeer }); + + assert.deepStrictEqual({ + hasRuntime: agent['_sessions'].has('session-unbound-peer-dispose'), + sessionBinding: agent['_sessionIdByChatUri'].get(sessionChat.toString()), + }, { + hasRuntime: true, + sessionBinding: 'session-unbound-peer-dispose', + }); + }); + test('disposeChat tears down the runtime of the addressed chat and forgets its binding', async () => { const agent = await createAgent(disposables, { sdkResolvableWithoutDownload: true }); const peer = disposables.add(createTestPeer()); @@ -1102,6 +1535,94 @@ suite('CodexAgent exact chat routing', () => { assert.doesNotThrow(() => agent['_schedulePrewarm'](entry)); }); + test('dispose during materialization removes a late managed directory and never starts a thread', async () => { + const agent = await createAgent(disposables); + const sessionUri = AgentSession.uri('codex', 'session-dispose-materializing'); + const chat = URI.parse(buildDefaultChatUri(sessionUri)); + const context = { configurationResource: sessionUri, resource: chat }; + await createSessionBackedChat(agent, chat, context); + const entry = agent['_sessions'].get(AgentSession.id(sessionUri))!; + const directory = URI.file('/tmp/codex-dispose-materializing'); + const directoryStarted = new DeferredPromise(); + const releaseDirectory = new DeferredPromise(); + const removed: string[] = []; + let connectionStarted = false; + const originalCreateManagedWorkingDirectory = agent['_createManagedWorkingDirectory'].bind(agent); + const originalRemoveManagedWorkingDirectory = agent['_removeManagedWorkingDirectory'].bind(agent); + const originalEnsureConnection = agent['_ensureConnection'].bind(agent); + agent['_createManagedWorkingDirectory'] = async () => { + await directoryStarted.complete(undefined); + await releaseDirectory.p; + return directory; + }; + agent['_removeManagedWorkingDirectory'] = async candidate => { removed.push(candidate.toString()); }; + agent['_ensureConnection'] = async () => { + connectionStarted = true; + throw new Error('disposed materialization reached the connection'); + }; + + try { + const materializing = agent['_materializeIfNeeded'](entry, sessionUri, false); + await directoryStarted.p; + await agent.chats.disposeChat(chat, context); + await releaseDirectory.complete(undefined); + await materializing; + + assert.deepStrictEqual({ + removed, + connectionStarted, + hasRuntime: agent['_sessions'].has(AgentSession.id(sessionUri)), + threadId: entry.threadId, + }, { + removed: [directory.toString()], + connectionStarted: false, + hasRuntime: false, + threadId: undefined, + }); + } finally { + agent['_createManagedWorkingDirectory'] = originalCreateManagedWorkingDirectory; + agent['_removeManagedWorkingDirectory'] = originalRemoveManagedWorkingDirectory; + agent['_ensureConnection'] = originalEnsureConnection; + } + }); + + test('dispose during an in-flight thread start archives the late thread instead of orphaning it', async () => { + const agent = await createAgent(disposables, { sdkResolvableWithoutDownload: true }); + const peer = disposables.add(createTestPeer()); + connectPeer(agent, peer); + + try { + const sessionUri = AgentSession.uri('codex', 'session-dispose-in-flight-start'); + const chat = URI.parse(buildDefaultChatUri(sessionUri)); + const context = { configurationResource: sessionUri, resource: chat }; + await createSessionBackedChat(agent, chat, context, { + workingDirectories: [URI.file('/repo/dispose-in-flight-start')], + model: { id: COPILOT_TEST_MODEL }, + }); + const entry = agent['_sessions'].get(AgentSession.id(sessionUri))!; + const materializing = agent['_materializeIfNeeded'](entry, sessionUri, false); + const start = await readNextRequest(peer.outbound); + + await agent.chats.disposeChat(chat, context); + peer.push({ id: start.id, result: { thread: { id: 'late-disposed-thread', cwd: '/repo/dispose-in-flight-start' } } }); + const cleanup = await readNextRequest(peer.outbound); + peer.push({ id: cleanup.id, result: {} }); + await materializing; + + assert.deepStrictEqual({ + cleanup: { method: cleanup.method, threadId: cleanup.params.threadId }, + hasRuntime: agent['_sessions'].has(AgentSession.id(sessionUri)), + hasBinding: agent['_sessionIdByChatUri'].has(chat.toString()), + }, { + cleanup: { method: 'thread/archive', threadId: 'late-disposed-thread' }, + hasRuntime: false, + hasBinding: false, + }); + } finally { + peer.dispose(); + } + }); + test('OTel: releaseChat preserves the runtime\'s trace context; a later disposeChat of the already-evicted runtime releases it through the scope-finalization path', async () => { const released: string[] = []; const agent = await createAgent(disposables, { @@ -1238,6 +1759,156 @@ suite('CodexAgent exact chat routing', () => { } }); + test('truncateChat resumes a replacement app-server before reading or rolling back', async () => { + const agent = await createAgent(disposables); + const peer = disposables.add(createTestPeer()); + connectPeer(agent, peer); + + try { + const session = AgentSession.uri('codex', 'resume-before-truncate'); + const chat = URI.parse(buildDefaultChatUri(session)); + const folder = URI.file('/repo/resume-before-truncate'); + await createSessionBackedChat(agent, chat, { configurationResource: session, resource: chat }, { + workingDirectories: [folder], + model: { id: COPILOT_TEST_MODEL }, + }); + const entry = agent['_sessions'].get(AgentSession.id(session))!; + entry.threadId = 'resume-before-truncate-thread'; + entry.needsResume = true; + agent['_sessionIdByThreadId'].set(entry.threadId, entry.sessionId); + + const truncating = agent.truncateChat(chat, 'keep-turn', { configurationResource: session, resource: chat }); + const resume = await readNextRequest(peer.outbound); + peer.push({ id: resume.id, result: { thread: { id: entry.threadId, cwd: folder.fsPath }, cwd: folder.fsPath } }); + const inventory = await readNextRequest(peer.outbound); + peer.push({ id: inventory.id, result: { data: [], nextCursor: null } }); + const read = await readNextRequest(peer.outbound); + peer.push({ id: read.id, result: { thread: { id: entry.threadId, cwd: folder.fsPath, turns: [{ id: 'keep-turn' }, { id: 'drop-turn' }] } } }); + const rollback = await readNextRequest(peer.outbound); + peer.push({ id: rollback.id, result: {} }); + await truncating; + + assert.deepStrictEqual([ + { method: resume.method, threadId: resume.params.threadId }, + { method: inventory.method, threadId: inventory.params.threadId }, + { method: read.method, threadId: read.params.threadId }, + { method: rollback.method, threadId: rollback.params.threadId, numTurns: rollback.params.numTurns }, + ], [ + { method: 'thread/resume', threadId: 'resume-before-truncate-thread' }, + { method: 'mcpServerStatus/list', threadId: 'resume-before-truncate-thread' }, + { method: 'thread/read', threadId: 'resume-before-truncate-thread' }, + { method: 'thread/rollback', threadId: 'resume-before-truncate-thread', numTurns: 1 }, + ]); + } finally { + peer.dispose(); + } + }); + + test('thread-scoped MCP calls resume a replacement app-server before forwarding', async () => { + const agent = await createAgent(disposables); + const peer = disposables.add(createTestPeer()); + connectPeer(agent, peer); + + try { + const session = AgentSession.uri('codex', 'resume-before-mcp'); + const chat = URI.parse(buildDefaultChatUri(session)); + const folder = URI.file('/repo/resume-before-mcp'); + await createSessionBackedChat(agent, chat, { configurationResource: session, resource: chat }, { + workingDirectories: [folder], + model: { id: COPILOT_TEST_MODEL }, + }); + const entry = agent['_sessions'].get(AgentSession.id(session))!; + entry.threadId = 'resume-before-mcp-thread'; + entry.needsResume = true; + agent['_sessionIdByThreadId'].set(entry.threadId, entry.sessionId); + agent['_mcpInventory'].replace(entry.threadId, new Map([['test-server', { + state: { kind: McpServerStatus.Ready }, + tools: [], + resources: [], + resourceTemplates: [], + }]])); + + const calling = agent.handleMcpRequest(chat, 'test-server', 'tools/call', { name: 'test-tool', arguments: {} }); + const resume = await readNextRequest(peer.outbound); + peer.push({ id: resume.id, result: { thread: { id: entry.threadId, cwd: folder.fsPath }, cwd: folder.fsPath } }); + const inventory = await readNextRequest(peer.outbound); + peer.push({ id: inventory.id, result: { data: [], nextCursor: null } }); + const toolCall = await readNextRequest(peer.outbound); + peer.push({ id: toolCall.id, result: { content: [] } }); + await calling; + + assert.deepStrictEqual([ + { method: resume.method, threadId: resume.params.threadId }, + { method: inventory.method, threadId: inventory.params.threadId }, + { method: toolCall.method, threadId: toolCall.params.threadId }, + ], [ + { method: 'thread/resume', threadId: 'resume-before-mcp-thread' }, + { method: 'mcpServerStatus/list', threadId: 'resume-before-mcp-thread' }, + { method: 'mcpServer/tool/call', threadId: 'resume-before-mcp-thread' }, + ]); + } finally { + peer.dispose(); + } + }); + + test('thread-scoped MCP calls retry resume when the app-server is replaced as resume completes', async () => { + const agent = await createAgent(disposables); + const firstPeer = disposables.add(createTestPeer()); + const secondPeer = disposables.add(createTestPeer()); + connectPeer(agent, firstPeer); + + try { + const session = AgentSession.uri('codex', 'replace-during-mcp-resume'); + const chat = URI.parse(buildDefaultChatUri(session)); + const folder = URI.file('/repo/replace-during-mcp-resume'); + await createSessionBackedChat(agent, chat, { configurationResource: session, resource: chat }, { + workingDirectories: [folder], + model: { id: COPILOT_TEST_MODEL }, + }); + const entry = agent['_sessions'].get(AgentSession.id(session))!; + entry.threadId = 'replace-during-mcp-resume-thread'; + entry.needsResume = true; + agent['_sessionIdByThreadId'].set(entry.threadId, entry.sessionId); + agent['_mcpInventory'].replace(entry.threadId, new Map([['test-server', { + state: { kind: McpServerStatus.Ready }, + tools: [], + resources: [], + resourceTemplates: [], + }]])); + + const calling = agent.handleMcpRequest(chat, 'test-server', 'tools/call', { name: 'test-tool', arguments: {} }); + const firstResume = await readNextRequest(firstPeer.outbound); + assert.strictEqual(firstResume.method, 'thread/resume'); + firstPeer.push({ id: firstResume.id, result: { thread: { id: entry.threadId, cwd: folder.fsPath }, cwd: folder.fsPath } }); + const lostConnection = agent['_connection']; + assert.strictEqual(lostConnection.kind, 'ready'); + agent['_handleConnectionLost'](lostConnection as never, agent['_connectionGeneration']); + connectPeer(agent, secondPeer); + + const secondResume = await readNextRequest(secondPeer.outbound); + assert.strictEqual(secondResume.method, 'thread/resume'); + secondPeer.push({ id: secondResume.id, result: { thread: { id: entry.threadId, cwd: folder.fsPath }, cwd: folder.fsPath } }); + const inventory = await readNextRequest(secondPeer.outbound); + secondPeer.push({ id: inventory.id, result: { data: [], nextCursor: null } }); + const toolCall = await readNextRequest(secondPeer.outbound); + secondPeer.push({ id: toolCall.id, result: { content: [] } }); + await calling; + + assert.deepStrictEqual([ + { method: firstResume.method, threadId: firstResume.params.threadId }, + { method: secondResume.method, threadId: secondResume.params.threadId }, + { method: toolCall.method, threadId: toolCall.params.threadId }, + ], [ + { method: 'thread/resume', threadId: 'replace-during-mcp-resume-thread' }, + { method: 'thread/resume', threadId: 'replace-during-mcp-resume-thread' }, + { method: 'mcpServer/tool/call', threadId: 'replace-during-mcp-resume-thread' }, + ]); + } finally { + firstPeer.dispose(); + secondPeer.dispose(); + } + }); + test('an active client is keyed to the exact addressed chat: no sibling inference, and cleanup on removal/disposal never touches a sibling chat', async () => { const agent = await createAgent(disposables, { sdkResolvableWithoutDownload: true }); const peer = disposables.add(createTestPeer()); @@ -1312,6 +1983,41 @@ suite('CodexAgent exact chat routing', () => { } }); + test('an eager active client retains its customizations for later removal', async () => { + const agent = await createAgent(disposables); + const session = AgentSession.uri('codex', 'session-eager-customizations'); + const chat = URI.parse(buildDefaultChatUri(session)); + const context = { configurationResource: session, resource: chat }; + const plugin = { + type: CustomizationType.Plugin, + id: 'plugin-eager', + uri: 'file:///plugin-eager', + name: 'Eager Plugin', + } as const; + let removed: readonly { readonly id: string }[] | undefined; + agent['_syncClientCustomizations'] = async () => { }; + agent['_removeClientCustomizations'] = async (_entry, _clientId, customizations) => { + removed = customizations; + }; + + await createSessionBackedChat(agent, chat, context, { + workingDirectories: [URI.file('/repo/eager-customizations')], + activeClient: { clientId: 'client-eager', tools: [], customizations: [plugin] }, + }); + const key = `${chat.toString()}\u0000client-eager`; + const retained = agent['_activeClientHandles'].get(key)?.customizations; + agent.removeActiveClient(chat, context, 'client-eager'); + await new Promise(resolve => setImmediate(resolve)); + + assert.deepStrictEqual({ + retained: retained?.map(customization => customization.id), + removed: removed?.map(customization => customization.id), + }, { + retained: ['plugin-eager'], + removed: ['plugin-eager'], + }); + }); + test('a peer chat\'s server-tool call uses its exact Agent Host chat channel', async () => { const agent = await createAgent(disposables, { sdkResolvableWithoutDownload: true }); const calls: { readonly method: 'requiresConfirmation' | 'executeTool'; readonly chatUri: string }[] = []; @@ -1420,6 +2126,420 @@ suite('CodexAgent chat backing durability', () => { } } + test('a thread started on a replaced app-server is resumed before its first turn', async () => { + const agent = await createAgent(disposables); + const firstPeer = disposables.add(createTestPeer()); + const secondPeer = disposables.add(createTestPeer()); + connect(agent, firstPeer); + const firstConnection = agent['_connection']; + assert.strictEqual(firstConnection.kind, 'ready'); + const session = AgentSession.uri('codex', 'start-response-reconnect-session'); + const chat = URI.parse(buildDefaultChatUri(session)); + const folder = URI.file('/repo/start-response-reconnect'); + + try { + await createSessionBackedChat(agent, chat, { configurationResource: session, resource: chat }, { + workingDirectories: [folder], + model: { id: COPILOT_TEST_MODEL }, + }); + const entry = agent['_sessions'].get(AgentSession.id(session))!; + const materializing = agent['_materializeIfNeeded'](entry, session, false); + const start = await readNextRequest(firstPeer.outbound); + assert.strictEqual(start.method, 'thread/start'); + + // The old process can finish a request after connection ownership has + // moved. Its thread exists durably, but it is not loaded in the new one. + connect(agent, secondPeer); + firstPeer.push({ id: start.id, result: { thread: { id: 'start-response-reconnect-thread', cwd: folder.fsPath } } }); + await materializing; + assert.strictEqual(entry.needsResume, true); + + const sending = agent.chats.sendMessage(chat, 'first turn', [folder], undefined, 'turn-1', undefined, undefined, { configurationResource: session, resource: chat }); + const resume = await readNextRequest(secondPeer.outbound); + assert.strictEqual(resume.method, 'thread/resume'); + secondPeer.push({ id: resume.id, result: { thread: { id: 'start-response-reconnect-thread', cwd: folder.fsPath }, cwd: folder.fsPath } }); + const inventory = await readNextRequest(secondPeer.outbound); + assert.strictEqual(inventory.method, 'mcpServerStatus/list'); + secondPeer.push({ id: inventory.id, result: { data: [], nextCursor: null } }); + const turn = await readNextRequest(secondPeer.outbound); + assert.strictEqual(turn.method, 'turn/start'); + secondPeer.push({ id: turn.id, result: {} }); + await sending; + + assert.deepStrictEqual({ + resumeThreadId: resume.params.threadId, + turnThreadId: turn.params.threadId, + needsResume: entry.needsResume, + }, { + resumeThreadId: 'start-response-reconnect-thread', + turnThreadId: 'start-response-reconnect-thread', + needsResume: false, + }); + } finally { + if (firstConnection.kind === 'ready') { + firstConnection.client.dispose(); + } + firstPeer.dispose(); + secondPeer.dispose(); + } + }); + + test('a replacement app-server resumes materialized sessions before their next turn', async () => { + const agent = await createAgent(disposables, { sdkResolvableWithoutDownload: true, sessionStore: createTestSessionStore() }); + const firstPeer = disposables.add(createTestPeer()); + const secondPeer = disposables.add(createTestPeer()); + connect(agent, firstPeer); + const session = AgentSession.uri('codex', 'reconnect-session'); + const chat = URI.parse(buildDefaultChatUri(session)); + const folder = URI.file('/repo/reconnect'); + + try { + await materializeSession(agent, firstPeer, session, chat, folder, 'reconnect-thread'); + const lostConnection = agent['_connection']; + assert.strictEqual(lostConnection.kind, 'ready'); + agent['_handleConnectionLost'](lostConnection as never, agent['_connectionGeneration']); + + const restored = agent['_sessions'].get(AgentSession.id(session))!; + assert.deepStrictEqual({ + connection: agent['_connection'].kind, + needsResume: restored.needsResume, + currentTurnId: restored.currentTurnId, + }, { + connection: 'idle', + needsResume: true, + currentTurnId: undefined, + }); + + connect(agent, secondPeer); + const sending = agent.chats.sendMessage(chat, 'after reconnect', [folder], undefined, 'turn-2', undefined, undefined, { configurationResource: session, resource: chat }); + const resume = await readNextRequest(secondPeer.outbound); + assert.strictEqual(resume.method, 'thread/resume'); + secondPeer.push({ id: resume.id, result: { thread: { id: 'reconnect-thread', cwd: folder.fsPath }, cwd: folder.fsPath } }); + const inventory = await readNextRequest(secondPeer.outbound); + assert.strictEqual(inventory.method, 'mcpServerStatus/list'); + secondPeer.push({ id: inventory.id, result: { data: [], nextCursor: null } }); + const turn = await readNextRequest(secondPeer.outbound); + secondPeer.push({ id: turn.id, result: {} }); + await sending; + + assert.deepStrictEqual({ + resumeThreadId: resume.params.threadId, + turn: { method: turn.method, threadId: turn.params.threadId }, + needsResume: restored.needsResume, + }, { + resumeThreadId: 'reconnect-thread', + turn: { method: 'turn/start', threadId: 'reconnect-thread' }, + needsResume: false, + }); + } finally { + firstPeer.dispose(); + secondPeer.dispose(); + } + }); + + test('drops thread history returned by a replaced app-server', async () => { + const agent = await createAgent(disposables, { sdkResolvableWithoutDownload: true }); + const firstPeer = disposables.add(createTestPeer()); + const secondPeer = disposables.add(createTestPeer()); + connect(agent, firstPeer); + const firstConnection = agent['_connection']; + assert.strictEqual(firstConnection.kind, 'ready'); + const session = AgentSession.uri('codex', 'stale-history-session'); + const chat = URI.parse(buildDefaultChatUri(session)); + const folder = URI.file('/repo/stale-history'); + + try { + await createSessionBackedChat(agent, chat, { configurationResource: session, resource: chat }, { + workingDirectories: [folder], + model: { id: COPILOT_TEST_MODEL }, + }); + const entry = agent['_sessions'].get(AgentSession.id(session))!; + entry.threadId = 'stale-history-thread'; + entry.needsResume = false; + agent['_sessionIdByThreadId'].set(entry.threadId, entry.sessionId); + + const reading = agent['_readSession'](session, true); + const staleRead = await readNextRequest(firstPeer.outbound); + assert.strictEqual(staleRead.method, 'thread/read'); + connect(agent, secondPeer); + firstPeer.push({ + id: staleRead.id, + result: { thread: { id: entry.threadId, cwd: folder.fsPath, turns: [{ id: 'stale-turn' }] } }, + }); + const currentRead = await readNextRequest(secondPeer.outbound); + assert.strictEqual(currentRead.method, 'thread/read'); + secondPeer.push({ + id: currentRead.id, + result: { thread: { id: entry.threadId, cwd: folder.fsPath, turns: [{ id: 'current-turn' }] } }, + }); + + assert.deepStrictEqual((await reading)?.thread.turns?.map(turn => turn.id), ['current-turn']); + } finally { + if (firstConnection.kind === 'ready') { + firstConnection.client.dispose(); + } + firstPeer.dispose(); + secondPeer.dispose(); + } + }); + + test('a disconnect during thread/resume retries on the replacement app-server', async () => { + const agent = await createAgent(disposables, { sdkResolvableWithoutDownload: true, sessionStore: createTestSessionStore() }); + const firstPeer = disposables.add(createTestPeer()); + const secondPeer = disposables.add(createTestPeer()); + const thirdPeer = disposables.add(createTestPeer()); + connect(agent, firstPeer); + const session = AgentSession.uri('codex', 'resume-request-reconnect-session'); + const chat = URI.parse(buildDefaultChatUri(session)); + const folder = URI.file('/repo/resume-request-reconnect'); + + try { + await materializeSession(agent, firstPeer, session, chat, folder, 'resume-request-reconnect-thread'); + const firstConnection = agent['_connection']; + assert.strictEqual(firstConnection.kind, 'ready'); + agent['_handleConnectionLost'](firstConnection as never, agent['_connectionGeneration']); + connect(agent, secondPeer); + + const sending = agent.chats.sendMessage(chat, 'retry resume', [folder], undefined, 'turn-2', undefined, undefined, { configurationResource: session, resource: chat }); + const interruptedResume = await readNextRequest(secondPeer.outbound); + assert.strictEqual(interruptedResume.method, 'thread/resume'); + const secondConnection = agent['_connection']; + assert.strictEqual(secondConnection.kind, 'ready'); + agent['_handleConnectionLost'](secondConnection as never, agent['_connectionGeneration']); + connect(agent, thirdPeer); + + const retriedResume = await readNextRequest(thirdPeer.outbound); + assert.strictEqual(retriedResume.method, 'thread/resume'); + thirdPeer.push({ id: retriedResume.id, result: { thread: { id: 'resume-request-reconnect-thread', cwd: folder.fsPath }, cwd: folder.fsPath } }); + const inventory = await readNextRequest(thirdPeer.outbound); + assert.strictEqual(inventory.method, 'mcpServerStatus/list'); + thirdPeer.push({ id: inventory.id, result: { data: [], nextCursor: null } }); + const turn = await readNextRequest(thirdPeer.outbound); + assert.strictEqual(turn.method, 'turn/start'); + thirdPeer.push({ id: turn.id, result: {} }); + await sending; + + assert.deepStrictEqual({ + interrupted: interruptedResume.params.threadId, + retried: retriedResume.params.threadId, + turn: turn.params.threadId, + }, { + interrupted: 'resume-request-reconnect-thread', + retried: 'resume-request-reconnect-thread', + turn: 'resume-request-reconnect-thread', + }); + } finally { + firstPeer.dispose(); + secondPeer.dispose(); + thirdPeer.dispose(); + } + }); + + test('a send carries a replacement connection forward after resuming on it', async () => { + const agent = await createAgent(disposables, { sdkResolvableWithoutDownload: true, sessionStore: createTestSessionStore() }); + const firstPeer = disposables.add(createTestPeer()); + const secondPeer = disposables.add(createTestPeer()); + connect(agent, firstPeer); + const session = AgentSession.uri('codex', 'mid-send-reconnect-session'); + const chat = URI.parse(buildDefaultChatUri(session)); + const folder = URI.file('/repo/mid-send-reconnect'); + + try { + await materializeSession(agent, firstPeer, session, chat, folder, 'mid-send-reconnect-thread'); + const buildCustomizationLaunch = agent['_buildCustomizationLaunch'].bind(agent); + let replaceDuringNextBuild = true; + agent['_buildCustomizationLaunch'] = async entry => { + const result = await buildCustomizationLaunch(entry); + if (replaceDuringNextBuild) { + replaceDuringNextBuild = false; + const lostConnection = agent['_connection']; + assert.strictEqual(lostConnection.kind, 'ready'); + agent['_handleConnectionLost'](lostConnection as never, agent['_connectionGeneration']); + connect(agent, secondPeer); + } + return result; + }; + + const sending = agent.chats.sendMessage(chat, 'after mid-send reconnect', [folder], undefined, 'turn-2', undefined, undefined, { configurationResource: session, resource: chat }); + const resume = await readNextRequest(secondPeer.outbound); + assert.strictEqual(resume.method, 'thread/resume'); + secondPeer.push({ id: resume.id, result: { thread: { id: 'mid-send-reconnect-thread', cwd: folder.fsPath }, cwd: folder.fsPath } }); + const inventory = await readNextRequest(secondPeer.outbound); + secondPeer.push({ id: inventory.id, result: { data: [], nextCursor: null } }); + const turn = await readNextRequest(secondPeer.outbound); + secondPeer.push({ id: turn.id, result: {} }); + await sending; + + assert.deepStrictEqual({ + resume: { method: resume.method, threadId: resume.params.threadId }, + turn: { method: turn.method, threadId: turn.params.threadId }, + }, { + resume: { method: 'thread/resume', threadId: 'mid-send-reconnect-thread' }, + turn: { method: 'turn/start', threadId: 'mid-send-reconnect-thread' }, + }); + } finally { + firstPeer.dispose(); + secondPeer.dispose(); + } + }); + + test('a disconnect after turn/start is sent finalizes the turn exactly once', async () => { + const agent = await createAgent(disposables, { sdkResolvableWithoutDownload: true, sessionStore: createTestSessionStore() }); + const peer = disposables.add(createTestPeer()); + connect(agent, peer); + const session = AgentSession.uri('codex', 'disconnect-during-turn-start'); + const chat = URI.parse(buildDefaultChatUri(session)); + const folder = URI.file('/repo/disconnect-during-turn-start'); + + try { + await materializeSession(agent, peer, session, chat, folder, 'disconnect-during-turn-start-thread'); + const signals: AgentSignal[] = []; + const listener = agent.onDidChatProgress(signal => signals.push(signal)); + try { + const sending = agent.chats.sendMessage(chat, 'disconnect now', [folder], undefined, 'turn-2', undefined, undefined, { configurationResource: session, resource: chat }); + const turn = await readNextRequest(peer.outbound); + assert.strictEqual(turn.method, 'turn/start'); + const lostConnection = agent['_connection']; + assert.strictEqual(lostConnection.kind, 'ready'); + agent['_handleConnectionLost'](lostConnection as never, agent['_connectionGeneration']); + await sending; + } finally { + listener.dispose(); + } + + assert.deepStrictEqual(signals.flatMap(signal => signal.kind === 'action' + ? [{ type: signal.action.type, errorType: signal.action.type === ActionType.ChatError ? signal.action.part.error.errorType : undefined }] + : []), [ + { type: ActionType.ChatError, errorType: 'CodexDisconnected' }, + { type: ActionType.ChatTurnComplete, errorType: undefined }, + ]); + } finally { + peer.dispose(); + } + }); + + test('passive archive changes use one-off connections without activating Codex', async () => { + const agent = await createAgent(disposables); + const archivePeer = disposables.add(createTestPeer()); + const unarchivePeer = disposables.add(createTestPeer()); + + try { + const session = AgentSession.uri('codex', 'idle-archive-session'); + const chat = URI.parse(buildDefaultChatUri(session)); + await createSessionBackedChat(agent, chat, { configurationResource: session, resource: chat }, { + workingDirectories: [URI.file('/repo/idle-archive')], + model: { id: COPILOT_TEST_MODEL }, + }); + const entry = agent['_sessions'].get(AgentSession.id(session))!; + entry.threadId = 'idle-archive-thread'; + agent['_sessionIdByThreadId'].set(entry.threadId, entry.sessionId); + agent['_activated'] = false; + agent['_connection'] = { kind: 'idle' }; + await agent['_startupAccountProbe'].p; + + const peers = [archivePeer, unarchivePeer]; + const disposed: string[] = []; + let connectionStarts = 0; + agent['_startRawConnection'] = async () => { + const peer = peers[connectionStarts++]; + return { + client: new CodexAppServerClient(peer.transport), + proxyHandle: { dispose: () => disposed.push(`proxy-${connectionStarts}`) }, + child: { kill: () => { disposed.push(`child-${connectionStarts}`); return true; } }, + } as never; + }; + + const archiving = agent.onArchivedChanged(session, true); + const archive = await readNextRequest(archivePeer.outbound); + archivePeer.push({ id: archive.id, result: {} }); + await archiving; + + const unarchiving = agent.onArchivedChanged(session, false); + const unarchive = await readNextRequest(unarchivePeer.outbound); + unarchivePeer.push({ id: unarchive.id, result: {} }); + await unarchiving; + + assert.deepStrictEqual({ + connectionStarts, + activated: agent['_activated'], + connection: agent['_connection'].kind, + disposed, + requests: [ + { method: archive.method, threadId: archive.params.threadId }, + { method: unarchive.method, threadId: unarchive.params.threadId }, + ], + }, { + connectionStarts: 2, + activated: false, + connection: 'idle', + disposed: ['proxy-1', 'child-1', 'proxy-2', 'child-2'], + requests: [ + { method: 'thread/archive', threadId: 'idle-archive-thread' }, + { method: 'thread/unarchive', threadId: 'idle-archive-thread' }, + ], + }); + } finally { + archivePeer.dispose(); + unarchivePeer.dispose(); + } + }); + + test('passive archive resolves a discovered thread from the session URI when no overlay exists', async () => { + const agent = await createAgent(disposables); + const peer = disposables.add(createTestPeer()); + const session = AgentSession.uri('codex', 'cold-discovered-thread'); + agent['_activated'] = false; + agent['_connection'] = { kind: 'idle' }; + await agent['_startupAccountProbe'].p; + let connectionStarts = 0; + agent['_startRawConnection'] = async () => { + connectionStarts++; + return { + client: new CodexAppServerClient(peer.transport), + proxyHandle: { dispose() { } }, + child: { kill: () => true }, + } as never; + }; + + const archiving = agent.onArchivedChanged(session, true); + const request = await readNextRequest(peer.outbound); + peer.push({ id: request.id, result: {} }); + await archiving; + + assert.deepStrictEqual({ + connectionStarts, + method: request.method, + threadId: request.params.threadId, + activated: agent['_activated'], + connection: agent['_connection'].kind, + }, { + connectionStarts: 1, + method: 'thread/archive', + threadId: 'cold-discovered-thread', + activated: false, + connection: 'idle', + }); + }); + + test('materializeChat advertises server tools for an already-restored runtime', async () => { + const agent = await createAgent(disposables); + const session = AgentSession.uri('codex', 'existing-restore-advertise'); + const chat = URI.parse(buildDefaultChatUri(session)); + const context = { configurationResource: session, resource: chat }; + const created = await createSessionBackedChat(agent, chat, context); + const entry = agent['_sessions'].get(AgentSession.id(session))!; + assert.strictEqual(entry.serverToolsAdvertisement, undefined); + const advertised: string[] = []; + agent.setServerToolHost(createRecordingServerToolHost(advertised)); + + await agent.materializeChat(chat, context, created.providerData); + + assert.deepStrictEqual({ advertised, serverToolsAdvertisement: entry.serverToolsAdvertisement }, { + advertised: [session.toString()], + serverToolsAdvertisement: session.toString(), + }); + }); + test('materializeChat rejects missing peer and corrupt default providerData', async () => { const agent = await createAgent(disposables); const session = AgentSession.uri('codex', 'invalid-backing'); @@ -1476,6 +2596,28 @@ suite('CodexAgent chat backing durability', () => { } }); + test('materializeChat rolls back a newly restored runtime when server-tool advertisement fails', async () => { + const agent = await createAgent(disposables); + const session = AgentSession.uri('codex', 'restore-fail-advertise'); + const chat = URI.parse(buildDefaultChatUri(session)); + agent.setServerToolHost(createThrowingAdvertiseServerToolHost('restore advertise boom')); + + await assert.rejects( + agent.materializeChat(chat, { configurationResource: session, resource: chat }, JSON.stringify({ sessionId: 'restored-backing' })), + /restore advertise boom/, + ); + + assert.deepStrictEqual({ + hasSession: agent['_sessions'].has('restored-backing'), + hasBinding: agent['_sessionIdByChatUri'].has(chat.toString()), + hasConfigScope: agent['_configScopeByChat'].has(chat.toString()), + }, { + hasSession: false, + hasBinding: false, + hasConfigScope: false, + }); + }); + test('the materialize receipt re-keys the chat backing onto the runtime, so a restored session stays addressable', async () => { const sessionStore = createTestSessionStore(); const session = AgentSession.uri('codex', 'host-session'); @@ -1500,13 +2642,14 @@ suite('CodexAgent chat backing durability', () => { const restoring = second.getChatMetadata(chat, { configurationResource: session, resource: chat }, receipt.result?.providerData); const originalProbe = await readNextRequest(secondPeer.outbound); assert.strictEqual(originalProbe.params.threadId, 'host-session'); + assert.strictEqual(originalProbe.params.includeTurns, false); secondPeer.push({ id: originalProbe.id, error: { code: -32000, message: 'thread not found' } }); const read = await readNextRequest(secondPeer.outbound); assert.strictEqual(read.params.threadId, 'codex-thread'); + assert.strictEqual(read.params.includeTurns, false); secondPeer.push({ id: read.id, result: { thread: { id: 'codex-thread', cwd: folder.fsPath, modelProvider: 'vscode-proxy', turns: [] } } }); await restoring; - const restoreInventory = await readNextRequest(secondPeer.outbound); - secondPeer.push({ id: restoreInventory.id, result: { data: [], nextCursor: null } }); + assert.strictEqual(secondPeer.outbound.readableLength, 0); await second.materializeChat(chat, { configurationResource: session, resource: chat }, receipt.result?.providerData); // Drive a turn on the restored chat and fail it at `turn/start`, so @@ -1574,10 +2717,10 @@ suite('CodexAgent chat backing durability', () => { const context = { configurationResource: addressed, resource: chat }; const restoring = agent.getChatMetadata(chat, context, JSON.stringify({ sessionId: 'backing-runtime' })); const read = await readNextRequest(peer.outbound); + assert.strictEqual(read.params.includeTurns, false); peer.push({ id: read.id, result: { thread: { id: 'backing-thread', cwd: '/repo/addressed', turns: [] } } }); const metadata = await restoring; - const inventory = await readNextRequest(peer.outbound); - peer.push({ id: inventory.id, result: { data: [], nextCursor: null } }); + assert.strictEqual(peer.outbound.readableLength, 0); const restored = agent['_sessions'].get('backing-runtime'); assert.deepStrictEqual({ @@ -1641,6 +2784,36 @@ suite('CodexAgent chat backing durability', () => { } }); + test('a live provisional runtime answers metadata without reading a nonexistent thread', async () => { + const agent = await createAgent(disposables); + const session = AgentSession.uri('codex', 'live-provisional-metadata'); + const chat = URI.parse(buildDefaultChatUri(session)); + const context = { configurationResource: session, resource: chat }; + const before = Date.now(); + const created = await createSessionBackedChat(agent, chat, context); + let reads = 0; + agent['_readSession'] = async () => { + reads++; + throw new Error('provisional metadata must not read an app-server thread'); + }; + + const metadata = await agent.getChatMetadata(chat, context, created.providerData); + + assert.deepStrictEqual({ + reads, + chat: metadata?.chat.toString(), + startedInThisRun: (metadata?.startTime ?? 0) >= before, + workingDirectories: metadata?.workingDirectories, + model: metadata?.model, + }, { + reads: 0, + chat: chat.toString(), + startedInThisRun: true, + workingDirectories: undefined, + model: { id: COPILOT_TEST_MODEL }, + }); + }); + test('a restored runtime preserves its thread summary in subsequent live metadata lookups', async () => { const agent = await createAgent(disposables, { sdkResolvableWithoutDownload: true, sessionStore: createTestSessionStore() }); const peer = disposables.add(createTestPeer()); @@ -1654,6 +2827,7 @@ suite('CodexAgent chat backing durability', () => { const restoring = agent.getChatMetadata(chat, context, providerData); const read = await readNextRequest(peer.outbound); assert.strictEqual(read.method, 'thread/read'); + assert.strictEqual(read.params.includeTurns, false); peer.push({ id: read.id, result: { @@ -1669,9 +2843,6 @@ suite('CodexAgent chat backing durability', () => { }); const coldMetadata = await restoring; - const inventory = await readNextRequest(peer.outbound); - assert.strictEqual(inventory.method, 'mcpServerStatus/list'); - peer.push({ id: inventory.id, result: { data: [], nextCursor: null } }); // The first lookup registers a live runtime. The second must retain // the title without another app-server request: that server may be // blocked waiting on the very dynamic tool call requesting metadata. diff --git a/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts b/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts index 454d853d952..24cfff4aea1 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts @@ -5,6 +5,8 @@ import type { CCAModel } from '@vscode/copilot-api'; import assert from 'assert'; +import { DeferredPromise } from '../../../../../base/common/async.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Event } from '../../../../../base/common/event.js'; import type { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { waitForState } from '../../../../../base/common/observable.js'; @@ -25,6 +27,8 @@ import { IAgentSdkDownloader } from '../../../node/agentSdkDownloader.js'; import { RecordingAgentSdkDownloader } from '../testAgentSdkDownloader.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../../common/agentHostCheckpointService.js'; import { AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY, AGENT_SDK_SETUP_RELOAD_REQUEST_KEY, readAgentSdkSetupInfos } from '../../../common/agentSdkSetup.js'; +import { AgentSession } from '../../../common/agent.js'; +import { buildDefaultChatUri } from '../../../common/state/sessionState.js'; import { CodexAgent, toCodexModelSelectionId } from '../../../node/codex/codexAgent.js'; import { ICodexProxyService } from '../../../node/codex/codexProxyService.js'; import { ICopilotApiService } from '../../../node/shared/copilotApiService.js'; @@ -35,12 +39,16 @@ import { IAgentHostOTelService } from '../../../common/otel/agentHostOTelService import { AgentHostConfigKey } from '../../../common/agentHostCustomizationConfig.js'; import { createNoopCustomizationEnablementService } from '../testCustomizationEnablementService.js'; import { createTestAgentHostProxyResolver } from '../agentServiceTestUtils.js'; +import { readCodexAccountInfo } from '../../../common/codexAccount.js'; +import type { GetAccountResponse } from '../../../node/codex/protocol/generated/v2/GetAccountResponse.js'; +import type { GetAccountRateLimitsResponse } from '../../../node/codex/protocol/generated/v2/GetAccountRateLimitsResponse.js'; interface ITestAgentContext { readonly agent: CodexAgent; readonly stateManager: AgentHostStateManager; readonly configurationService: AgentConfigurationService; readonly sdkDownloader: RecordingAgentSdkDownloader; + readonly runStartupAccountProbe: () => Promise; } /** @@ -71,7 +79,9 @@ function createAgentContext(disposables: Pick, models: ( instantiationService.stub(INativeEnvironmentService, { userHome: URI.file('/tmp') }); instantiationService.stub(ILogService, logService); const agent = disposables.add(instantiationService.createInstance(CodexAgent)); - return { agent, stateManager, configurationService, sdkDownloader }; + const runStartupAccountProbe = agent['_probeAccountAtStartup'].bind(agent); + agent['_probeAccountAtStartup'] = async () => { }; + return { agent, stateManager, configurationService, sdkDownloader, runStartupAccountProbe }; } function createAgent(disposables: Pick, models: () => Promise, rootConfig: Record = {}, sdkDownloader = new RecordingAgentSdkDownloader()): CodexAgent { @@ -139,28 +149,49 @@ suite('CodexAgent model refresh', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('eagerly enumerates the authoritative catalog at startup when the SDK is already local', async () => { + test('keeps the persistent app-server stopped until a Codex session is selected', async () => { const agent = createAgent(disposables, async () => [], { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }); const requests: string[] = []; - let resolveConnection!: () => void; - const connectionPromise = new Promise(resolve => { resolveConnection = () => resolve(createChatGPTConnection(undefined, requests) as never); }); + const connection = createChatGPTConnection(undefined, requests); let connectionRequested = false; agent['_ensureConnection'] = async () => { connectionRequested = true; - return connectionPromise; + agent['_connection'] = connection as never; + return connection as never; }; + // These are all ambient registration/startup paths in AgentService. None is + // an affirmative choice to use Codex. + const discoveryListener = agent.onDidDiscoverChats(() => { }); + const migrated = await agent.listChatsToMigrate(); + const session = AgentSession.uri('codex', 'existing-session'); + const metadata = await agent.getChatMetadata(URI.parse(buildDefaultChatUri(session)), session); + await agent.authenticate(agent.getProtectedResources()[0].resource, 'token-replayed-at-registration'); await new Promise(resolve => setTimeout(resolve, 0)); - assert.deepStrictEqual({ connectionRequested, models: agent.models.get() }, { connectionRequested: true, models: [] }); + discoveryListener.dispose(); + assert.deepStrictEqual({ connectionRequested, metadata, migrated, models: agent.models.get() }, { + connectionRequested: false, + metadata: undefined, + migrated: [], + models: [], + }); - resolveConnection(); + // Even an ambient catalog refresh must not cross the session boundary. + await agent.refreshModels(); + assert.strictEqual(connectionRequested, false); + + // Session creation/restoration crosses the activation boundary; its catalog + // refresh may now retain the app-server connection. + agent['_activate'](); await agent.refreshModels(); assert.deepStrictEqual({ + connectionRequested, // One enumeration, not one per caller that happened to want the connection. enumerations: requests.filter(method => method === 'model/list').length, models: agent.models.get().map(model => ({ provider: model.provider, id: model.id, name: model.name, meta: model._meta })), }, { + connectionRequested: true, enumerations: 1, models: [{ provider: 'chatgpt', @@ -171,6 +202,474 @@ suite('CodexAgent model refresh', () => { }); }); + test('queues a fresh model refresh when Codex activates during an ambient refresh', async () => { + const copilotModels = [{ id: 'copilot-model', name: 'Copilot Model', supported_endpoints: ['/responses'] }] as CCAModel[]; + const ambientRefreshStarted = new DeferredPromise(); + const ambientCodexRefreshFinished = new DeferredPromise(); + const releaseAmbientRefresh = new DeferredPromise(); + let copilotRefreshes = 0; + const agent = createAgent(disposables, async () => { + copilotRefreshes++; + if (copilotRefreshes === 1) { + await ambientRefreshStarted.complete(); + await releaseAmbientRefresh.p; + } + return copilotModels; + }, { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }); + agent['_githubToken'] = 'token'; + agent['_refreshProviderConfiguration'] = async () => { }; + const refreshCodexModels = agent['_refreshCodexModels'].bind(agent); + let codexRefreshes = 0; + agent['_refreshCodexModels'] = async () => { + const result = await refreshCodexModels(); + codexRefreshes++; + if (codexRefreshes === 1) { + await ambientCodexRefreshFinished.complete(); + } + return result; + }; + const requests: string[] = []; + const connection = createChatGPTConnection(undefined, requests); + agent['_ensureConnection'] = async () => { + agent['_connection'] = connection as never; + return connection as never; + }; + + const ambientRefresh = agent.refreshModels(); + await Promise.all([ambientRefreshStarted.p, ambientCodexRefreshFinished.p]); + agent['_activate'](); + const activatedRefresh = agent.refreshModels(); + await releaseAmbientRefresh.complete(); + await Promise.all([ambientRefresh, activatedRefresh]); + + assert.deepStrictEqual({ + copilotRefreshes, + codexRefreshes, + enumerations: requests.filter(method => method === 'model/list').length, + providers: agent.models.get().map(model => model.provider), + }, { + copilotRefreshes: 2, + codexRefreshes: 2, + enumerations: 1, + providers: ['copilot', 'chatgpt'], + }); + }); + + test('an explicit session restore activates metadata reads while ambient listing stays passive', async () => { + const ctx = createAgentContext(disposables, async () => []); + const session = AgentSession.uri('codex', 'restore-activation'); + const chat = URI.parse(buildDefaultChatUri(session)); + let reads = 0; + ctx.agent['_refreshProviderConfiguration'] = async () => { }; + ctx.agent['_readSession'] = async () => { + reads++; + return undefined; + }; + + const ambient = await ctx.agent.getChatMetadata(chat, session); + const activatedAfterAmbient = ctx.agent['_activated']; + const fallback = await ctx.agent.getChatMetadata(chat, session, undefined, { registryFallback: { startTime: 1, modifiedTime: 2 } }); + const restored = await ctx.agent.getChatMetadata(chat, session, undefined, { activation: 'restore' }); + + assert.deepStrictEqual({ + ambient, + activatedAfterAmbient, + fallback, + restored, + activatedAfterRestore: ctx.agent['_activated'], + reads, + }, { + ambient: undefined, + activatedAfterAmbient: false, + fallback: { chat, startTime: 1, modifiedTime: 2 }, + restored: undefined, + activatedAfterRestore: true, + reads: 1, + }); + }); + + test('startup account probe releases its one-off process before profile download finishes and still publishes complete details', async () => { + const ctx = createAgentContext(disposables, async () => []); + const requests: string[] = []; + const disposed: string[] = []; + const rateLimitStarted = new DeferredPromise(); + const releaseRateLimit = new DeferredPromise(); + const profileImageStarted = new DeferredPromise(); + const releaseProfileImage = new DeferredPromise(); + const profileImageStored = new DeferredPromise(); + const profileImageNonce = 'a'.repeat(64); + const profileImage = { + uri: `vscode-codex-profile-image:/profile-${profileImageNonce}.png`, + contentType: 'image/png', + sizeHint: 3, + nonce: profileImageNonce, + }; + ctx.agent['_proxyResolver'].fetch = async () => { + await profileImageStarted.complete(); + await releaseProfileImage.p; + return Response.json({ profile: { profile_picture_url: 'data:image/png;base64,AQID' } }); + }; + ctx.agent['_getProfileImageStore'] = () => ({ + update: async () => { + await profileImageStored.complete(); + return profileImage; + }, + clear: async () => { }, + }) as never; + ctx.agent['_startRawConnection'] = async () => ({ + client: { + request: async (method: string) => { + requests.push(method); + if (method === 'account/read') { + return { account: { type: 'chatgpt', email: 'person@example.com', planType: 'plus' }, requiresOpenaiAuth: true }; + } + if (method === 'account/rateLimits/read') { + await rateLimitStarted.complete(); + await releaseRateLimit.p; + return { + rateLimits: { + primary: null, + secondary: { usedPercent: 1, windowDurationMins: 7 * 24 * 60, resetsAt: 123 }, + }, + rateLimitsByLimitId: null, + rateLimitResetCredits: null, + }; + } + if (method === 'getAuthStatus') { + return { authMethod: 'chatgpt', authToken: 'header.payload.signature', requiresOpenaiAuth: true }; + } + throw new Error(`Unexpected request: ${method}`); + }, + dispose: () => { disposed.push('client'); }, + }, + proxyHandle: { dispose: () => { disposed.push('proxy'); } }, + child: { kill: () => { disposed.push('child'); return true; } }, + }) as never; + + const probe = ctx.runStartupAccountProbe(); + await Promise.all([rateLimitStarted.p, profileImageStarted.p]); + assert.deepStrictEqual(disposed, []); + await releaseRateLimit.complete(); + await probe; + assert.deepStrictEqual(disposed, ['client', 'proxy', 'child']); + await releaseProfileImage.complete(); + await profileImageStored.p; + await new Promise(resolve => setImmediate(resolve)); + + assert.deepStrictEqual({ + requests, + disposed, + account: readCodexAccountInfo(ctx.stateManager.rootState), + connection: ctx.agent['_connection'].kind, + }, { + requests: ['account/read', 'account/rateLimits/read', 'getAuthStatus'], + disposed: ['client', 'proxy', 'child'], + account: { + status: 'signedIn', + email: 'person@example.com', + planType: 'plus', + profileImage, + requiresOpenaiAuth: true, + rateLimit: { usedPercent: 1, windowDurationMins: 7 * 24 * 60, resetsAt: 123 }, + authUrl: undefined, + authUrlNonce: undefined, + }, + connection: 'idle', + }); + }); + + test('startup account probe tears down its one-off connection when account details stall', async () => { + const ctx = createAgentContext(disposables, async () => []); + Object.defineProperty(ctx.agent, '_startupAccountProbeTimeoutMs', { value: 5 }); + const disposed: string[] = []; + const rateLimitStarted = new DeferredPromise(); + const releaseRateLimit = new DeferredPromise(); + const authStatusStarted = new DeferredPromise(); + const releaseAuthStatus = new DeferredPromise(); + ctx.agent['_startRawConnection'] = async () => ({ + client: { + request: async (method: string) => { + if (method === 'account/read') { + return { account: { type: 'chatgpt', email: 'person@example.com', planType: 'plus' }, requiresOpenaiAuth: true }; + } + if (method === 'account/rateLimits/read') { + await rateLimitStarted.complete(); + await releaseRateLimit.p; + return { rateLimits: { primary: null, secondary: null }, rateLimitsByLimitId: null, rateLimitResetCredits: null }; + } + if (method === 'getAuthStatus') { + await authStatusStarted.complete(); + await releaseAuthStatus.p; + return { authMethod: 'chatgpt', authToken: null, requiresOpenaiAuth: true }; + } + throw new Error(`Unexpected request: ${method}`); + }, + dispose: () => { disposed.push('client'); }, + }, + proxyHandle: { dispose: () => { disposed.push('proxy'); } }, + child: { kill: () => { disposed.push('child'); return true; } }, + }) as never; + + const probe = ctx.runStartupAccountProbe(); + await Promise.all([rateLimitStarted.p, authStatusStarted.p]); + await probe; + + assert.deepStrictEqual({ + disposed, + account: readCodexAccountInfo(ctx.stateManager.rootState), + connection: ctx.agent['_connection'].kind, + }, { + disposed: ['client', 'proxy', 'child'], + account: { + status: 'signedIn', + email: 'person@example.com', + planType: 'plus', + profileImage: undefined, + requiresOpenaiAuth: true, + rateLimit: undefined, + authUrl: undefined, + authUrlNonce: undefined, + }, + connection: 'idle', + }); + + const persistentReadStarted = new DeferredPromise(); + const persistentClient = { + request: async (method: string) => { + assert.strictEqual(method, 'account/read'); + await persistentReadStarted.complete(undefined); + return { account: null, requiresOpenaiAuth: true }; + }, + }; + ctx.agent['_connection'] = { + kind: 'ready', + client: persistentClient, + proxyHandle: { dispose() { } }, + child: { kill: () => true }, + } as never; + const persistentRefresh = ctx.agent['_refreshAccount'](persistentClient as never, false); + await new Promise(resolve => setImmediate(resolve)); + const persistentReadStartedBeforeDetailsReleased = persistentReadStarted.isSettled; + await Promise.all([releaseRateLimit.complete(), releaseAuthStatus.complete()]); + await persistentRefresh; + + assert.strictEqual(persistentReadStartedBeforeDetailsReleased, true); + }); + + test('startup account probe does not download a missing SDK', async () => { + const ctx = createAgentContext(disposables, async () => []); + ctx.agent['_isSdkResolvableWithoutDownload'] = async () => false; + let connectionRequests = 0; + ctx.agent['_startRawConnection'] = async () => { + connectionRequests++; + throw new Error('startup probe must not download'); + }; + await ctx.runStartupAccountProbe(); + + assert.deepStrictEqual({ + connectionRequests, + account: readCodexAccountInfo(ctx.stateManager.rootState), + }, { + connectionRequests: 0, + account: { status: 'unknown', email: undefined, planType: undefined, profileImage: undefined, requiresOpenaiAuth: undefined, rateLimit: undefined, authUrl: undefined, authUrlNonce: undefined }, + }); + }); + + test('standalone ChatGPT sign-in uses a temporary connection until login completes', async () => { + const ctx = createAgentContext(disposables, async () => []); + const requests: string[] = []; + const disposed: string[] = []; + let signedIn = false; + let loginCompleted: ((params: { loginId: string | null; success: boolean; error: string | null }) => void) | undefined; + ctx.agent['_startRawConnection'] = async () => ({ + client: { + onExit: Event.None, + request: async (method: string) => { + requests.push(method); + if (method === 'account/read') { + return { account: signedIn ? { type: 'chatgpt', email: 'person@example.com', planType: 'plus' } : null, requiresOpenaiAuth: true }; + } + if (method === 'account/login/start') { + queueMicrotask(() => { + loginCompleted?.({ loginId: 'older-login', success: true, error: null }); + }); + setImmediate(() => { + signedIn = true; + loginCompleted?.({ loginId: 'login-1', success: true, error: null }); + }); + return { type: 'chatgpt', loginId: 'login-1', authUrl: 'https://example.com/login' }; + } + if (method === 'account/rateLimits/read') { + return { rateLimits: { primary: null, secondary: null }, rateLimitsByLimitId: null, rateLimitResetCredits: null }; + } + if (method === 'getAuthStatus') { + return { authMethod: 'chatgpt', authToken: null, requiresOpenaiAuth: true }; + } + throw new Error(`Unexpected request: ${method}`); + }, + onNotification: (_method: string, handler: typeof loginCompleted) => { + loginCompleted = handler; + return { dispose() { } }; + }, + dispose: () => { disposed.push('client'); }, + }, + proxyHandle: { dispose: () => { disposed.push('proxy'); } }, + child: { kill: () => { disposed.push('child'); return true; } }, + }) as never; + + await ctx.agent['_signInToChatGPT']('request-1'); + + assert.deepStrictEqual({ + requests, + disposed, + account: readCodexAccountInfo(ctx.stateManager.rootState), + connection: ctx.agent['_connection'].kind, + }, { + requests: ['account/read', 'account/login/start', 'account/read', 'account/rateLimits/read', 'getAuthStatus'], + disposed: ['client', 'proxy', 'child'], + account: { status: 'signedIn', email: 'person@example.com', planType: 'plus', profileImage: undefined, requiresOpenaiAuth: true, rateLimit: undefined, authUrl: undefined, authUrlNonce: undefined }, + connection: 'idle', + }); + }); + + test('persistent sign-in does not republish an auth URL after an early login completion', async () => { + const ctx = createAgentContext(disposables, async () => []); + const requests: string[] = []; + const client = { + request: async (method: string) => { + requests.push(method); + if (method === 'account/read') { + return { account: null, requiresOpenaiAuth: true }; + } + if (method === 'account/login/start') { + // Model the persistent connection's global completion handler + // winning the race against this request's response. + ctx.agent['_setOpenAIAccountState']({ + usageSource: 'openai', + status: 'signedIn', + authType: 'chatgpt', + email: 'person@example.com', + planType: 'plus', + requiresOpenaiAuth: true, + }); + return { type: 'chatgpt', loginId: 'login-early', authUrl: 'https://example.com/obsolete-login' }; + } + throw new Error(`Unexpected request: ${method}`); + }, + }; + ctx.agent['_connection'] = { + kind: 'ready', + client, + proxyHandle: { dispose() { } }, + child: { kill: () => true }, + } as never; + + await ctx.agent['_signInToChatGPT']('request-early'); + + assert.deepStrictEqual({ + requests, + account: readCodexAccountInfo(ctx.stateManager.rootState), + }, { + requests: ['account/read', 'account/login/start'], + account: { + status: 'signedIn', + email: 'person@example.com', + planType: 'plus', + profileImage: undefined, + requiresOpenaiAuth: true, + rateLimit: undefined, + authUrl: undefined, + authUrlNonce: undefined, + }, + }); + }); + + test('shutdown cancels a one-off account connection that is still starting', async () => { + const agent = createAgent(disposables, async () => []); + await agent['_startupAccountProbe'].complete(undefined); + const started = new DeferredPromise(); + const release = new DeferredPromise(); + const cancelled = new DeferredPromise(); + const disposed: string[] = []; + const ready = { + client: { dispose: () => disposed.push('client') }, + proxyHandle: { dispose: () => disposed.push('proxy') }, + child: { kill: () => { disposed.push('child'); return true; } }, + }; + agent['_startRawConnection'] = (async (_timeout?: number, token?: CancellationToken) => { + const cancellationListener = token?.onCancellationRequested(() => { + ready.client.dispose(); + ready.proxyHandle.dispose(); + ready.child.kill(); + void cancelled.complete(); + }); + await started.complete(); + await (token ? Promise.race([release.p, cancelled.p]) : release.p); + cancellationListener?.dispose(); + if (token?.isCancellationRequested) { + throw new Error('start cancelled'); + } + return ready; + }) as never; + + const operation = agent['_withOnDemandConnection'](async () => undefined); + const rejected = assert.rejects(operation); + await started.p; + await agent.shutdown(); + const disposedAtShutdown = [...disposed]; + await release.complete(); + await rejected; + + assert.deepStrictEqual(disposedAtShutdown, ['client', 'proxy', 'child']); + }); + + test('shutdown suppresses a local-SDK model refresh queued before shutdown', async () => { + const agent = createAgent(disposables, async () => [], { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }); + agent['_activated'] = true; + const sdkCheckStarted = new DeferredPromise(); + const releaseSdkCheck = new DeferredPromise(); + let refreshes = 0; + agent['_isSdkResolvableWithoutDownload'] = async () => { + await sdkCheckStarted.complete(undefined); + await releaseSdkCheck.p; + return true; + }; + agent.refreshModels = async () => { refreshes++; }; + + agent['_startModelRefreshWhenSdkIsLocal'](); + await sdkCheckStarted.p; + await agent.shutdown(); + await releaseSdkCheck.complete(undefined); + await new Promise(resolve => setImmediate(resolve)); + + assert.strictEqual(refreshes, 0); + }); + + test('shutdown suppresses chat discovery whose SDK check was already in flight', async () => { + const agent = createAgent(disposables, async () => []); + agent['_activated'] = true; + const sdkCheckStarted = new DeferredPromise(); + const releaseSdkCheck = new DeferredPromise(); + let catalogueReads = 0; + agent['_isSdkResolvableWithoutDownload'] = async () => { + await sdkCheckStarted.complete(undefined); + await releaseSdkCheck.p; + return true; + }; + agent['_emitCodexChats'] = async () => { + catalogueReads++; + return true; + }; + + const discovery = agent['_startCodexChatDiscovery'](); + await sdkCheckStarted.p; + await agent.shutdown(); + await releaseSdkCheck.complete(undefined); + await discovery; + + assert.strictEqual(catalogueReads, 0); + }); + test('does not enumerate at startup while signed-out use is disabled', async () => { const agent = createAgent(disposables, async () => [], {}); const requests: string[] = []; @@ -245,7 +744,11 @@ suite('CodexAgent model refresh', () => { const agent = createAgent(disposables, async () => [], {}); const connection = createChatGPTConnection(); let resolveConnection!: () => void; - agent['_connection'] = { kind: 'starting', promise: new Promise(resolve => { resolveConnection = () => resolve(connection as never); }) }; + const starting = new Promise(resolve => { resolveConnection = () => resolve(connection); }).then(ready => { + agent['_connection'] = ready as never; + return ready; + }); + agent['_connection'] = { kind: 'starting', promise: starting } as never; agent['_configurationService'].updateRootConfig({ [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }); await new Promise(resolve => setTimeout(resolve, 0)); @@ -426,6 +929,318 @@ suite('CodexAgent model refresh', () => { assert.deepStrictEqual(appliedTokens, ['token-arriving-during-start']); }); + test('cancels an app-server that is still starting when shutdown begins', async () => { + const agent = createAgent(disposables, async () => []); + const started = new DeferredPromise(); + const release = new DeferredPromise(); + const cancelled = new DeferredPromise(); + const disposed: string[] = []; + const ready = { + client: { dispose: () => disposed.push('client') }, + proxyHandle: { dispose: () => disposed.push('proxy') }, + child: { kill: () => { disposed.push('child'); return true; } }, + }; + agent['_startConnection'] = (async (_generation: number, token?: CancellationToken) => { + const cancellationListener = token?.onCancellationRequested(() => { + ready.client.dispose(); + ready.proxyHandle.dispose(); + ready.child.kill(); + void cancelled.complete(); + }); + await started.complete(); + await (token ? Promise.race([release.p, cancelled.p]) : release.p); + cancellationListener?.dispose(); + if (token?.isCancellationRequested) { + throw new Error('start cancelled'); + } + return ready; + }) as never; + + const connecting = agent['_ensureConnection'](); + await started.p; + await agent.shutdown(); + const disposedAtShutdown = [...disposed]; + await release.complete(); + await assert.rejects(connecting); + + assert.deepStrictEqual(disposedAtShutdown, ['client', 'proxy', 'child']); + }); + + test('ignores a delayed connection-loss event from a replaced client', () => { + const agent = createAgent(disposables, async () => []); + const disposed: string[] = []; + const stale = { + client: { dispose: () => disposed.push('stale-client') }, + proxyHandle: { dispose: () => disposed.push('stale-proxy') }, + child: { kill: () => { disposed.push('stale-child'); return true; } }, + }; + const current = { + client: { dispose: () => disposed.push('current-client') }, + proxyHandle: { dispose: () => disposed.push('current-proxy') }, + child: { kill: () => { disposed.push('current-child'); return true; } }, + }; + agent['_connectionGeneration'] = 4; + agent['_connection'] = { kind: 'ready', ...current } as never; + + // Both the generation and client identity protect the replacement: the + // first models a queued event from the prior generation; the second guards + // against a callback whose bookkeeping was stale but generation was not. + agent['_handleConnectionLost'](stale as never, 3); + agent['_handleConnectionLost'](stale as never, 4); + + assert.deepStrictEqual({ + isCurrentClient: agent['_isCurrentConnection'](current as never), + disposed, + }, { + isCurrentClient: true, + disposed: [], + }); + }); + + test('does not promote a connection that dies while startup is completing', async () => { + const agent = createAgent(disposables, async () => []); + const disposed: string[] = []; + agent['_startConnection'] = async generation => { + // Let `_ensureConnection` publish its `starting` state before simulating + // an exit in the narrow window before this promise resolves. + await Promise.resolve(); + const ready = { + client: { dispose: () => disposed.push('client') }, + proxyHandle: { dispose: () => disposed.push('proxy') }, + child: { kill: () => { disposed.push('child'); return true; } }, + subscriptions: { dispose: () => disposed.push('subscriptions') }, + }; + agent['_handleConnectionLost'](ready as never, generation); + return ready as never; + }; + + await assert.rejects(agent['_ensureConnection'](), /replaced while starting/); + + assert.strictEqual(agent['_connection'].kind, 'idle'); + assert.deepStrictEqual(disposed, ['subscriptions', 'client', 'proxy', 'child']); + }); + + test('rejects an app-server that exited before persistent listeners were attached', async () => { + const agent = createAgent(disposables, async () => []); + const disposed: string[] = []; + const registration = () => ({ dispose() { } }); + agent['_startRawConnection'] = async () => ({ + client: { + onExit: Event.None, + onTransportError: Event.None, + onNotification: registration, + onRequest: registration, + dispose: () => { disposed.push('client'); }, + }, + proxyHandle: { dispose: () => { disposed.push('proxy'); } }, + child: { + exitCode: 1, + signalCode: null, + kill: () => { disposed.push('child'); return false; }, + }, + }) as never; + + await assert.rejects(agent['_startConnection'](0, CancellationToken.None), /exited before persistent startup completed/); + + assert.deepStrictEqual(disposed, ['client', 'proxy', 'child']); + }); + + test('drops a model catalog returned by a replaced app-server', async () => { + const agent = createAgent(disposables, async () => []); + agent['_activated'] = true; + const modelListStarted = new DeferredPromise(); + const releaseModelList = new DeferredPromise(); + const staleConnection = { + kind: 'ready', + client: { + request: async (method: string) => { + if (method === 'account/read') { + return { account: { type: 'chatgpt', email: 'old@example.com', planType: 'plus' }, requiresOpenaiAuth: true }; + } + if (method === 'config/read') { + return { config: { model_provider: 'openai' } }; + } + if (method === 'model/list') { + await modelListStarted.complete(); + await releaseModelList.p; + return modelListResponse; + } + throw new Error(`Unexpected request: ${method}`); + }, + }, + proxyHandle: { dispose() { } }, + child: { kill: () => true }, + }; + agent['_connection'] = staleConnection as never; + + const refreshing = agent['_refreshCodexModels'](); + await modelListStarted.p; + const currentModels = [{ provider: 'chatgpt', id: toCodexModelSelectionId('openai', 'current-model'), name: 'Current Model', supportsVision: false }]; + agent['_codexModels'] = currentModels; + agent['_connection'] = createChatGPTConnection() as never; + await releaseModelList.complete(); + await refreshing; + + assert.strictEqual(agent['_codexModels'], currentModels); + }); + + test('drops provider configuration returned by a replaced app-server', async () => { + const ctx = createAgentContext(disposables, async () => []); + ctx.agent['_activated'] = true; + const configReadStarted = new DeferredPromise(); + const releaseConfigRead = new DeferredPromise(); + ctx.agent['_connection'] = { + kind: 'ready', + client: { + request: async (method: string) => { + assert.strictEqual(method, 'config/read'); + await configReadStarted.complete(); + await releaseConfigRead.p; + return { + config: {}, + layers: [{ name: { type: 'user', profile: null }, config: { personality: 'friendly', auto_review: { policy: 'always' } } }], + }; + }, + }, + proxyHandle: { dispose() { } }, + child: { kill: () => true }, + } as never; + + const refreshing = ctx.agent['_refreshProviderConfiguration'](); + await configReadStarted.p; + ctx.agent['_connection'] = createChatGPTConnection() as never; + await releaseConfigRead.complete(); + await refreshing; + + assert.deepStrictEqual({ + ready: ctx.agent['_providerConfigurationReady'], + values: ctx.agent['_providerConfigurationValues'], + }, { + ready: false, + values: {}, + }); + }); + + test('serializes account reads so later refreshes publish last', async () => { + const agent = createAgent(disposables, async () => []); + const firstStarted = new DeferredPromise(); + const secondStarted = new DeferredPromise(); + const requestStarted = [firstStarted, secondStarted]; + const firstResponse = new DeferredPromise(); + const secondResponse = new DeferredPromise(); + const responses = [ + firstResponse, + secondResponse, + ]; + let requestIndex = 0; + const client = { + request: async (method: string) => { + assert.strictEqual(method, 'account/read'); + const index = requestIndex++; + await requestStarted[index].complete(); + return responses[index].p; + }, + } as never; + agent['_connection'] = { + kind: 'ready', + client, + proxyHandle: { dispose() { } }, + child: { kill: () => true }, + } as never; + + const first = agent['_refreshAccount'](client, false); + const second = agent['_refreshAccount'](client, false); + await firstStarted.p; + assert.strictEqual(requestIndex, 1); + await firstResponse.complete({ account: null, requiresOpenaiAuth: true }); + await first; + + await secondStarted.p; + assert.strictEqual(requestIndex, 2); + await secondResponse.complete({ + account: { type: 'chatgpt', email: 'new@example.com', planType: 'pro' }, + requiresOpenaiAuth: true, + }); + await second; + + assert.deepStrictEqual(agent['_openAIAccountState'], { + usageSource: 'openai', + status: 'signedIn', + authType: 'chatgpt', + email: 'new@example.com', + planType: 'pro', + requiresOpenaiAuth: true, + }); + }); + + test('drops a thread catalog returned by a replaced app-server', async () => { + const agent = createAgent(disposables, async () => []); + const listStarted = new DeferredPromise(); + const releaseList = new DeferredPromise(); + const staleConnection = { + kind: 'ready', + client: { + request: async (method: string) => { + assert.strictEqual(method, 'thread/list'); + await listStarted.complete(); + await releaseList.p; + return { data: [], nextCursor: null }; + }, + }, + proxyHandle: { dispose() { } }, + child: { kill: () => true }, + }; + agent['_connection'] = staleConnection as never; + + const listing = agent['_listCodexChats'](); + await listStarted.p; + agent['_connection'] = createChatGPTConnection() as never; + await releaseList.complete(); + + assert.strictEqual(await listing, undefined); + }); + + test('keeps the newest rate-limit response when reads complete out of order', async () => { + const agent = createAgent(disposables, async () => []); + let resolveFirst!: (value: GetAccountRateLimitsResponse) => void; + let resolveSecond!: (value: GetAccountRateLimitsResponse) => void; + const responses = [ + new Promise(resolve => resolveFirst = resolve), + new Promise(resolve => resolveSecond = resolve), + ]; + let requestIndex = 0; + const client = { + request: async (method: string) => { + assert.strictEqual(method, 'account/rateLimits/read'); + return responses[requestIndex++]; + }, + } as never; + agent['_connection'] = { + kind: 'ready', + client, + proxyHandle: { dispose() { } }, + child: { kill: () => true }, + } as never; + agent['_openAIAccountState'] = { usageSource: 'openai', status: 'signedIn', authType: 'chatgpt', email: 'person@example.com', planType: 'plus', requiresOpenaiAuth: true }; + + const first = agent['_refreshAccountRateLimits'](client, 'person@example.com'); + const second = agent['_refreshAccountRateLimits'](client, 'person@example.com'); + resolveSecond({ + rateLimits: { limitId: null, limitName: null, primary: { usedPercent: 20, windowDurationMins: 300, resetsAt: 200 }, secondary: null, credits: null, individualLimit: null, spendControlReached: null, planType: null, rateLimitReachedType: null }, + rateLimitsByLimitId: null, + rateLimitResetCredits: null, + }); + await second; + resolveFirst({ + rateLimits: { limitId: null, limitName: null, primary: { usedPercent: 90, windowDurationMins: 300, resetsAt: 100 }, secondary: null, credits: null, individualLimit: null, spendControlReached: null, planType: null, rateLimitReachedType: null }, + rateLimitsByLimitId: null, + rateLimitResetCredits: null, + }); + await first; + + assert.deepStrictEqual(agent['_openAIAccountRateLimit'], { usedPercent: 20, windowDurationMins: 300, resetsAt: 200 }); + }); + test('surfaces current ChatGPT subscription models under the ChatGPT provider', async () => { const agent = createAgent(disposables, async () => []); agent['_connection'] = { @@ -794,39 +1609,24 @@ suite('CodexAgent — agent SDK setup channel', () => { }); }); - test('a download that lands stays `downloading` until the catalog does, so the banner never flashes "no account"', async () => { + test('a download that lands publishes ready without starting a persistent catalog connection', async () => { const sdkDownloader = createNotDownloaded(); sdkDownloader.loadSdkRootResult = async () => { sdkDownloader.resolvableWithoutDownload = true; return '/tmp/codex-sdk'; }; const ctx = createAgentContext(disposables, async () => [], {}, sdkDownloader); - let releaseEnumeration = () => { }; - const enumerated = new Promise(resolve => { releaseEnumeration = resolve; }); - const connection = createChatGPTConnection(); - ctx.agent['_ensureConnection'] = async () => ({ - ...connection, - client: { - request: async (method: string) => { - if (method === 'model/list') { - await enumerated; - } - return connection.client.request(method); - }, - }, - } as never); + let connectionRequests = 0; + ctx.agent['_ensureConnection'] = async () => { + connectionRequests++; + throw new Error('persistent connection should remain stopped'); + }; await settle(); dispatchDownload(ctx); await settle(); - const enumerating = { download: readSetup(ctx)?.download, models: ctx.agent.models.get().length }; - releaseEnumeration(); - await settle(); - - assert.deepStrictEqual({ enumerating, after: readSetup(ctx)?.download, models: ctx.agent.models.get().length }, { - // `ready` while the catalog is still empty is precisely how the window - // renders "we looked and found no account". - enumerating: { download: 'downloading', models: 0 }, - after: 'ready', - models: 1, + assert.deepStrictEqual({ download: readSetup(ctx)?.download, models: ctx.agent.models.get().length, connectionRequests }, { + download: 'ready', + models: 0, + connectionRequests: 0, }); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts b/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts index a34430b41ee..2a3e6d00299 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts @@ -64,6 +64,7 @@ interface ITestWireRequest { readonly params: { readonly cwd?: string; readonly threadId?: string; + readonly includeTurns?: boolean; readonly runtimeWorkspaceRoots?: readonly string[]; readonly model?: string; readonly modelProvider?: string; @@ -77,6 +78,7 @@ interface ITestWireRequest { const COPILOT_TEST_MODEL = toCodexModelSelectionId('vscode-proxy', 'gpt-test'); const OPENAI_TEST_MODEL = toCodexModelSelectionId('openai', 'gpt-5.6-sol'); +const PLUGIN_SKILLS_ROOT = URI.file('/plugin/skills').fsPath; interface ITestPeer { readonly transport: ICodexAppServerTransport; @@ -231,6 +233,8 @@ async function createAgent(disposables: Pick, options: I instantiationService.stub(IFileService, fileService); instantiationService.stub(ILogService, logService); const agent = disposables.add(instantiationService.createInstance(CodexAgent)); + agent['_probeAccountAtStartup'] = async () => { }; + agent['_activated'] = true; await agent.authenticate(agent.getProtectedResources()[0].resource, 'test-token'); await agent.refreshModels(); return agent; @@ -596,6 +600,16 @@ suite('CodexAgent prewarm eviction', () => { const configurationResource = AgentSession.uri('codex', 'cleanup'); const chat = defaultChatOf(configurationResource); const configurationKey = configurationResource.toString(); + const created = await createSession(agent, { session: configurationResource }); + const runtime = agent['_sessions'].get(AgentSession.id(created.session))!; + runtime.threadId = 'shutdown-prewarm'; + runtime.prewarmClaimed = false; + let postShutdownConnections = 0; + agent['_ensureConnection'] = async () => { + postShutdownConnections++; + return { client: { request: async () => ({}) } } as never; + }; + runtime.prewarmTimer = setTimeout(() => { void agent['_expirePrewarm'](runtime); }, 0); agent['_desktopThreadIds'].add('desktop-thread'); agent['_sessionIdByChatUri'].set(chat.toString(), 'runtime'); agent['_sessionIdByThreadId'].set('thread', 'runtime'); @@ -610,8 +624,12 @@ suite('CodexAgent prewarm eviction', () => { agent.getOrCreateActiveClient(chat, { configurationResource, resource: chat }, { clientId: 'client' }); await agent.shutdown(); + await new Promise(resolve => setTimeout(resolve, 0)); assert.deepStrictEqual({ + runtimeDisposed: runtime.disposed, + prewarmTimer: runtime.prewarmTimer, + postShutdownConnections, desktopThreads: agent['_desktopThreadIds'].size, activeClients: agent['_activeClientHandles'].size, chatBindings: agent['_sessionIdByChatUri'].size, @@ -625,6 +643,9 @@ suite('CodexAgent prewarm eviction', () => { mcpAuthTokens: agent['_mcpAuthTokens'].size, mcpAuthResources: agent['_mcpAuthServerUrlsByResource'].size, }, { + runtimeDisposed: true, + prewarmTimer: undefined, + postShutdownConnections: 0, desktopThreads: 0, activeClients: 0, chatBindings: 0, @@ -640,6 +661,35 @@ suite('CodexAgent prewarm eviction', () => { }); }); + test('shutdown rejects an exact-chat lifecycle operation that was still queued', async () => { + const agent = await createAgent(disposables); + const session = AgentSession.uri('codex', 'queued-after-shutdown'); + const chat = defaultChatOf(session); + const blockerStarted = new DeferredPromise(); + const releaseBlocker = new DeferredPromise(); + const blocker = agent['_chatLifecycleSequencer'].queue(chat.toString(), async () => { + blockerStarted.complete(); + await releaseBlocker.p; + }); + await blockerStarted.p; + const queuedCreate = agent.chats.createChat(chat, chatContext(session, chat), { deferBacking: true }); + + await agent.shutdown(); + releaseBlocker.complete(); + await blocker; + await assert.rejects(queuedCreate); + + assert.deepStrictEqual({ + sessions: agent['_sessions'].size, + chatBindings: agent['_sessionIdByChatUri'].size, + connection: agent['_connection'].kind, + }, { + sessions: 0, + chatBindings: 0, + connection: 'idle', + }); + }); + test('peer client customization publication and removal target the owning session and reload MCP state', async () => { const agent = await createAgent(disposables); agent['_schedulePrewarm'] = () => { }; @@ -1267,6 +1317,251 @@ suite('CodexAgent prewarm eviction', () => { }); }); + test('skill catalog refresh removes directory customizations that disappeared', async () => { + const agent = await createAgent(disposables); + agent['_schedulePrewarm'] = () => { }; + const { session } = await createSession(agent, { workingDirectories: [URI.file('/repo')] }); + const entry = agent['_sessions'].get(AgentSession.id(session))!; + const container = (id: string) => ({ + type: CustomizationType.Directory, + id, + uri: URI.file(`/repo/.agents/skills/${id}`), + name: id, + enabled: true, + contents: CustomizationType.Skill, + writable: false, + children: [], + }) as never; + let catalog = [container('old-skill-container')]; + agent['_fetchSkillHookContainers'] = async () => catalog; + const signals: AgentSignal[] = []; + disposables.add(agent.onDidChatProgress(signal => signals.push(signal))); + + await agent['_refreshSkillHookCustomizations'](entry); + catalog = [container('new-skill-container')]; + await agent['_refreshSkillHookCustomizations'](entry); + + assert.deepStrictEqual(signals.flatMap(signal => signal.kind === 'action' + && (signal.action.type === ActionType.SessionCustomizationUpdated || signal.action.type === ActionType.SessionCustomizationRemoved) + ? [{ + type: signal.action.type, + id: signal.action.type === ActionType.SessionCustomizationUpdated ? signal.action.customization.id : signal.action.id, + }] + : []), [ + { type: ActionType.SessionCustomizationUpdated, id: 'old-skill-container' }, + { type: ActionType.SessionCustomizationRemoved, id: 'old-skill-container' }, + { type: ActionType.SessionCustomizationUpdated, id: 'new-skill-container' }, + ]); + }); + + test('skill catalog refreshes are serialized so an older result cannot replace a newer one', async () => { + const agent = await createAgent(disposables); + agent['_schedulePrewarm'] = () => { }; + const { session } = await createSession(agent, { workingDirectories: [URI.file('/repo')] }); + const entry = agent['_sessions'].get(AgentSession.id(session))!; + const container = (id: string) => ({ + type: CustomizationType.Directory, + id, + uri: URI.file(`/repo/.agents/skills/${id}`), + name: id, + enabled: true, + contents: CustomizationType.Skill, + writable: false, + children: [], + }) as never; + const firstStarted = new DeferredPromise(); + const releaseFirst = new DeferredPromise(); + let calls = 0; + agent['_fetchSkillHookContainers'] = async () => { + calls++; + if (calls === 1) { + firstStarted.complete(); + await releaseFirst.p; + return [container('old-skill-container')]; + } + return [container('new-skill-container')]; + }; + const signals: AgentSignal[] = []; + disposables.add(agent.onDidChatProgress(signal => signals.push(signal))); + + const first = agent['_refreshSkillHookCustomizations'](entry); + await firstStarted.p; + const second = agent['_refreshSkillHookCustomizations'](entry); + await new Promise(resolve => setImmediate(resolve)); + const callsWhileFirstPending = calls; + releaseFirst.complete(); + await Promise.all([first, second]); + + assert.deepStrictEqual({ + callsWhileFirstPending, + published: [...entry.publishedDirectoryCustomizationIds], + actions: signals.flatMap(signal => signal.kind === 'action' + && (signal.action.type === ActionType.SessionCustomizationUpdated || signal.action.type === ActionType.SessionCustomizationRemoved) + ? [{ + type: signal.action.type, + id: signal.action.type === ActionType.SessionCustomizationUpdated ? signal.action.customization.id : signal.action.id, + }] + : []), + }, { + callsWhileFirstPending: 1, + published: ['new-skill-container'], + actions: [ + { type: ActionType.SessionCustomizationUpdated, id: 'old-skill-container' }, + { type: ActionType.SessionCustomizationRemoved, id: 'old-skill-container' }, + { type: ActionType.SessionCustomizationUpdated, id: 'new-skill-container' }, + ], + }); + }); + + test('initial customization snapshot discards skill and hook catalogs returned by a replaced app-server', async () => { + const agent = await createAgent(disposables); + agent['_schedulePrewarm'] = () => { }; + const { session } = await createSession(agent, { workingDirectories: [URI.file('/repo')] }); + const chat = defaultChatOf(session); + const entry = agent['_sessions'].get(AgentSession.id(session))!; + const requestsStarted = new DeferredPromise(); + const releaseRequests = new DeferredPromise(); + let requestCount = 0; + const staleClient = { + request: async (method: string) => { + requestCount++; + if (requestCount === 2) { + requestsStarted.complete(); + } + await releaseRequests.p; + return method === 'skills/list' ? { + data: [{ + cwd: '/repo', + skills: [{ + name: 'stale-skill', + description: 'from the replaced process', + path: '/repo/.agents/skills/stale-skill/SKILL.md', + scope: 'repo', + enabled: true, + }], + errors: [], + }], + } : { data: [] }; + }, + }; + agent['_connection'] = { + kind: 'ready', + client: staleClient, + child: { kill: () => true }, + } as never; + + const snapshot = agent.getChatCustomizations(chat, chatContext(session, chat)); + await requestsStarted.p; + agent['_connection'] = { + kind: 'ready', + client: { request: async () => ({ data: [] }) }, + child: { kill: () => true }, + } as never; + releaseRequests.complete(); + const customizations = await snapshot; + + assert.deepStrictEqual({ + directoryNames: customizations + .filter(customization => customization.type === CustomizationType.Directory) + .map(customization => customization.name), + publishedDirectoryIds: [...entry.publishedDirectoryCustomizationIds], + }, { + directoryNames: [], + publishedDirectoryIds: [], + }); + }); + + test('skill extra-root updates are serialized and recompute the latest union before sending', async () => { + const agent = await createAgent(disposables); + const { session } = await createSession(agent); + const entry = agent['_sessions'].get(AgentSession.id(session))!; + let includeSkill = true; + agent['_enabledClientPlugins'] = () => includeSkill ? [{ + parsed: { skills: [{ uri: URI.file('/plugin/skills/example/SKILL.md') }] }, + }] as never : []; + const firstStarted = new DeferredPromise(); + const releaseFirst = new DeferredPromise(); + const requests: string[][] = []; + agent['_connection'] = { + kind: 'ready', + client: { + request: async (method: string, params: { readonly extraRoots: string[] }) => { + assert.strictEqual(method, 'skills/extraRoots/set'); + requests.push(params.extraRoots); + if (requests.length === 1) { + firstStarted.complete(); + await releaseFirst.p; + } + return {}; + }, + }, + proxyHandle: { dispose() { } }, + child: { kill: () => true }, + } as never; + + const first = agent['_refreshSkillExtraRoots'](); + await firstStarted.p; + includeSkill = false; + const second = agent['_refreshSkillExtraRoots'](); + await new Promise(resolve => setImmediate(resolve)); + const requestsWhileFirstPending = requests.length; + releaseFirst.complete(); + await Promise.all([first, second]); + + assert.deepStrictEqual({ + requestsWhileFirstPending, + requests, + runtime: entry.sessionId, + }, { + requestsWhileFirstPending: 1, + requests: [[PLUGIN_SKILLS_ROOT], []], + runtime: AgentSession.id(session), + }); + }); + + test('every persistent app-server receives the current skill extra roots before it is returned', async () => { + const agent = await createAgent(disposables); + agent['_schedulePrewarm'] = () => { }; + await createSession(agent); + agent['_enabledClientPlugins'] = () => [{ + parsed: { skills: [{ uri: URI.file('/plugin/skills/example/SKILL.md') }] }, + }] as never; + const rootsByConnection: string[][][] = []; + agent['_startConnection'] = (async () => { + const roots: string[][] = []; + rootsByConnection.push(roots); + return { + client: { + request: async (method: string, params: { readonly extraRoots?: string[] }) => { + if (method === 'skills/extraRoots/set') { + roots.push(params.extraRoots ?? []); + return {}; + } + if (method === 'account/read') { + return { account: null, requiresOpenaiAuth: true }; + } + if (method === 'mcpServerStatus/list') { + return { data: [], nextCursor: null }; + } + throw new Error(`Unexpected request: ${method}`); + }, + dispose() { }, + }, + proxyHandle: { setToken() { }, dispose() { } }, + child: { kill: () => true }, + }; + }) as never; + + await agent['_ensureConnection'](); + agent['_disposeConnection'](); + await agent['_ensureConnection'](); + + assert.deepStrictEqual(rootsByConnection, [ + [[PLUGIN_SKILLS_ROOT]], + [[PLUGIN_SKILLS_ROOT]], + ]); + }); + test('disposing a released workspace-less peer removes its managed directory', async () => { const agent = await createAgent(disposables); agent['_schedulePrewarm'] = () => { }; @@ -1308,6 +1603,51 @@ suite('CodexAgent prewarm eviction', () => { peer.exit(); }); + test('changing the model of an idle-released chat persists the new selection', async () => { + const agent = await createAgent(disposables); + agent['_schedulePrewarm'] = () => { }; + agent['_refreshSkillHookCustomizations'] = async () => { }; + agent['_refreshSkillExtraRoots'] = async () => { }; + const peer = disposables.add(createTestPeer()); + agent['_connection'] = { + kind: 'ready', + client: new CodexAppServerClient(peer.transport), + usageSource: 'github', + child: { kill: () => true }, + } as never; + const alternateModel = toCodexModelSelectionId('vscode-proxy', 'gpt-alternate'); + agent['_models'].set([ + { provider: 'copilot', id: COPILOT_TEST_MODEL, name: 'GPT Test', supportsVision: false }, + { provider: 'copilot', id: alternateModel, name: 'GPT Alternate', supportsVision: false }, + ], undefined); + + const created = await createSession(agent, { workingDirectories: [URI.file('/repo/released-model')], model: { id: COPILOT_TEST_MODEL } }); + const chat = defaultChatOf(created.session); + const entry = agent['_sessions'].get(AgentSession.id(created.session))!; + const materializing = agent['_materializeIfNeeded'](entry, created.session, false); + const start = await readNextRequest(peer.outbound); + peer.push({ id: start.id, result: { thread: { id: 'released-model-thread' } } }); + await materializing; + + const releasing = agent.chats.releaseChat?.(chat, chatContext(created.session, chat)); + const unsubscribe = await readNextRequest(peer.outbound); + peer.push({ id: unsubscribe.id, result: {} }); + await releasing; + await agent.chats.changeModel(chat, { id: alternateModel }, chatContext(created.session, chat)); + + const overlay = await agent['_metadataStore'].read(created.session); + assert.deepStrictEqual({ + hasLiveRuntime: agent['_sessions'].has(AgentSession.id(created.session)), + boundRuntime: agent['_sessionIdByChatUri'].get(chat.toString()), + modelId: overlay.modelId, + }, { + hasLiveRuntime: false, + boundRuntime: AgentSession.id(created.session), + modelId: alternateModel, + }); + peer.exit(); + }); + test('routes provider-qualified models independently and switches one session', async () => { const agent = await createAgent(disposables); agent['_schedulePrewarm'] = () => { }; @@ -1343,7 +1683,10 @@ suite('CodexAgent prewarm eviction', () => { peer.push({ id: chatGPTStart.id, result: { thread: { id: 'thread-chatgpt' } } }); await materializeChatGPT; - await agent.chats.changeModel(defaultChatOf(copilot.session), { id: chatGPTModel }, chatContext(copilot.session, defaultChatOf(copilot.session))); + const switchingModel = agent.chats.changeModel(defaultChatOf(copilot.session), { id: chatGPTModel }, chatContext(copilot.session, defaultChatOf(copilot.session))); + const unsubscribe = await readNextRequest(peer.outbound); + peer.push({ id: unsubscribe.id, result: {} }); + await switchingModel; const persistedAfterSwitch = await agent['_metadataStore'].read(copilot.session); const rematerializeCopilot = agent['_materializeIfNeeded'](copilotEntry, copilotEntry.sessionUri, false); const switchedStart = await readNextRequest(peer.outbound); @@ -1357,6 +1700,7 @@ suite('CodexAgent prewarm eviction', () => { copilotThread: copilotEntry.threadId, chatGPTThread: chatGPTEntry.threadId, persistedAfterSwitch: persistedAfterSwitch.modelId, + unsubscribedThread: unsubscribe.params.threadId, }, { copilotStart: { model: 'gpt-test', provider: 'vscode-proxy' }, chatGPTStart: { model: 'gpt-test', provider: 'openai' }, @@ -1364,6 +1708,7 @@ suite('CodexAgent prewarm eviction', () => { copilotThread: 'thread-copilot-switched', chatGPTThread: 'thread-chatgpt', persistedAfterSwitch: chatGPTModel, + unsubscribedThread: 'thread-copilot', }); peer.exit(); @@ -2227,8 +2572,10 @@ suite('CodexAgent prewarm eviction', () => { const metadataPromise = agentB.getChatMetadata(restoredChat, { configurationResource: created.session, resource: restoredChat }); const originalProbe = await readNextRequest(peerB.outbound); assert.strictEqual(originalProbe.params.threadId, AgentSession.id(created.session)); + assert.strictEqual(originalProbe.params.includeTurns, false); peerB.push({ id: originalProbe.id, error: { code: -32000, message: 'thread not found' } }); const read = await readNextRequest(peerB.outbound); + assert.strictEqual(read.params.includeTurns, false); peerB.push({ id: read.id, result: { @@ -2241,13 +2588,12 @@ suite('CodexAgent prewarm eviction', () => { }, }); const metadata = await metadataPromise; - const initialStatus = await readNextRequest(peerB.outbound); - assert.strictEqual(initialStatus.method, 'mcpServerStatus/list'); - peerB.push({ id: initialStatus.id, result: { data: [], nextCursor: null } }); + assert.strictEqual(peerB.outbound.readableLength, 0); - // The restored session-backed chat is never rebound through a - // session-addressed seam: Agent Host addresses it by its exact chat - // URI plus the transient owning-session context. + // Mirror Agent Host restore: metadata discovery identifies the cold + // runtime, then the chat's opaque backing re-attaches that runtime to + // this exact chat before any chat-addressed operation can reach it. + await agentB.materializeChat(restoredChat, { configurationResource: created.session, resource: restoredChat }, created.providerData); const resumedSend = agentB.chats.sendMessage(restoredChat, 'again', undefined, undefined, 'turn-2', undefined, undefined, { configurationResource: created.session, resource: restoredChat }); const reloadUnsubscribe = await readNextRequest(peerB.outbound); assert.strictEqual(reloadUnsubscribe.method, 'thread/unsubscribe'); @@ -2347,6 +2693,7 @@ suite('CodexAgent prewarm eviction', () => { const metadataPromise = agent.getChatMetadata(chat, context); const metadataRead = await readNextRequest(peer.outbound); + assert.strictEqual(metadataRead.params.includeTurns, false); peer.push({ id: metadataRead.id, result: { @@ -2361,9 +2708,9 @@ suite('CodexAgent prewarm eviction', () => { }, }); const metadata = await metadataPromise; - const metadataInventory = await readNextRequest(peer.outbound); - peer.push({ id: metadataInventory.id, result: { data: [], nextCursor: null } }); + assert.strictEqual(peer.outbound.readableLength, 0); const restored = agent['_sessions'].get(AgentSession.id(session)); + await agent.materializeChat(chat, context, JSON.stringify({ sessionId: AgentSession.id(session) })); const historyPromise = agent.chats.getMessages(chat, context); const resume = await readNextRequest(peer.outbound); @@ -2377,6 +2724,7 @@ suite('CodexAgent prewarm eviction', () => { const resumeInventory = await readNextRequest(peer.outbound); peer.push({ id: resumeInventory.id, result: { data: [], nextCursor: null } }); const historyRead = await readNextRequest(peer.outbound); + assert.strictEqual(historyRead.params.includeTurns, true); peer.push({ id: historyRead.id, result: { diff --git a/src/vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test.ts b/src/vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test.ts index df0dcfd6be0..516c14b9cff 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test.ts @@ -54,7 +54,9 @@ function createAgent(disposables: Pick): CodexAgent { instantiationService.stub(IProductService, { _serviceBrand: undefined, version: '1.0.0-test' } as IProductService); instantiationService.stub(INativeEnvironmentService, { userHome: URI.file('/tmp') }); instantiationService.stub(ILogService, logService); - return disposables.add(instantiationService.createInstance(CodexAgent)); + const agent = disposables.add(instantiationService.createInstance(CodexAgent)); + agent['_probeAccountAtStartup'] = async () => { }; + return agent; } suite('codexSessionConfigKeys', () => { diff --git a/src/vs/platform/agentHost/test/node/codex/codexSessionTitleSpans.test.ts b/src/vs/platform/agentHost/test/node/codex/codexSessionTitleSpans.test.ts index f898c433f55..cd63907482c 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexSessionTitleSpans.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexSessionTitleSpans.test.ts @@ -85,7 +85,8 @@ function createTestContext(disposables: Pick): { stateMa instantiationService.stub(IProductService, { _serviceBrand: undefined, version: '1.0.0-test' } as IProductService); instantiationService.stub(INativeEnvironmentService, { userHome: URI.file('/tmp') }); instantiationService.stub(ILogService, logService); - disposables.add(instantiationService.createInstance(CodexAgent)); + const agent = disposables.add(instantiationService.createInstance(CodexAgent)); + agent['_probeAccountAtStartup'] = async () => { }; return { stateManager, otelService }; } From 8ae285809c4d3a2e888b6407b450edae3469c58b Mon Sep 17 00:00:00 2001 From: joshspicer <23246594+joshspicer@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:10:00 -0700 Subject: [PATCH 060/116] managed settings: forceRemoteSettingsRefresh fails closed (#332388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * policy: add managed-settings freshness contract Groundwork for making `forceRemoteSettingsRefresh` a real fail-closed startup gate (microsoft/vscode-internalbacklog#8825). Contract only — no behavior change, and nothing gates on freshness yet. Adds `managedSettingsFreshness.ts`, declaring the state machine shared by the fetch path, the policy gate and Policy Diagnostics so those consumers cannot drift: `NotRequired` / `Pending` / `Satisfied` / `Blocked`, the failure categories every inability-to-refresh maps to, and scoping by account + provider + endpoint so satisfaction is never transferable across accounts or GHE hosts. Replaces `shouldForceRemoteSettingsRefresh` with `resolveForceRemoteSettingsRefresh`, which resolves through `pickManagedSettings` instead of re-implementing precedence. Two fixes fall out: the file channel now participates (the old helper read only native MDM and server, silently ignoring managed-file delivery), and an explicit managed `false` is now distinguishable from an absent value, which a later change needs in order to know when the requirement may be cleared. The old helper had no production caller — it was left orphaned when 661f18fdeb7 reworked the managed-settings fetch — so this is inert. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: enforce freshness invariants in the type Address PR feedback: `IManagedSettingsFreshness` was a bag of optional fields, so a consumer could construct `Blocked` with no failure, `Satisfied` with no scope, or attach `httpStatus`/`retryAfter` to states where they mean nothing — leaving the fetch, gate and diagnostics consumers free to drift despite the type. Models it as a discriminated union instead, so each active state requires the fields its contract defines. `Blocked` is itself a union keyed on the failure category, so a status code is required for an HTTP error, a backoff deadline for rate limiting, and neither is accepted elsewhere. `source` is now the shared `ManagedSettingsChannel` rather than `string`, and is required on the effective states, which also encodes that it is never `'none'` once a channel has supplied the control. Adds `@ts-expect-error` coverage for the three rejected shapes: the directives fail the build if any shape becomes constructible again. `isSameManagedSettingsFreshnessScope` is now a private helper with required arguments — the union guarantees a scope is present, so its undefined-tolerance was unreachable, and nothing outside this module used it. Also trims two over-long comments flagged in review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: fail closed on forced managed settings refresh Require a fresh managed-settings response before enabling AI features when forceRemoteSettingsRefresh is effective. Preserve recovery through sign-in and retry, expose diagnostics, and cover native, server, file, failure, scope, and sign-out behavior. Related to microsoft/vscode-internalbacklog#8825. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: tighten managed settings recovery UX Re-render the Agents window when freshness failure details change and preserve startup notification deferral. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: address managed settings review feedback Scope cached server controls before precedence, avoid expired rate-limit poll loops, and align update-required recovery guidance across workbench and Agents window UI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: await explicit managed settings recovery refresh Classic web initialization intentionally skips the default-account fetch. Exercise the explicit refresh path before asserting the no-token fail-closed state so the browser suite observes the same lifecycle it is validating. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: provide product name in policy overlay fixture Ensure managed-settings messages render Code - OSS instead of an undefined product label in component screenshots. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: add managed settings failure modes Let the mock policy server return HTTP errors, malformed JSON, immediate disconnects, or no response until client timeout through presets, the GUI, and the control API. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: tighten forced managed settings recovery Improve forced-refresh progress and blocked-state UX, bound automatic retries after failures, preserve the ungoverned cache path, and simplify mock policy failure controls. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: close managed settings dialog on retry Start the explicit managed-settings refresh without making the dialog wait for the network request to complete. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: separate managed settings requirement copy Place the organization requirement and fetch failure remediation in separate paragraphs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: address managed settings review feedback Preserve cached and blocked freshness state, report failed manual syncs, retain pending mock-server edits, and include attempted scope in diagnostics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: reduce managed settings freshness implementation Trim redundant contract commentary and tests, simplify refresh resolution, deduplicate failure transitions, and keep no-flag tests independent from retry bypass behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../github-managed-settings.md | 11 +- .../local-testing.md | 8 + scripts/mock-policy-server/README.md | 37 +- scripts/mock-policy-server/endpoints.ts | 9 + scripts/mock-policy-server/public/app.ts | 46 +- scripts/mock-policy-server/public/index.html | 74 ++- scripts/mock-policy-server/public/style.css | 11 + scripts/mock-policy-server/server.ts | 57 +- .../inlineCompletions/test/browser/utils.ts | 4 +- .../standalone/browser/standaloneServices.ts | 4 +- .../defaultAccount/common/defaultAccount.ts | 17 +- .../policy/common/copilotManagedSettings.ts | 25 +- .../policy/common/fileManagedSettingsIpc.ts | 16 +- .../common/fileManagedSettingsService.ts | 27 +- .../policy/common/managedSettingsFreshness.ts | 81 +++ .../common/copilotManagedSettings.test.ts | 31 +- .../common/fileManagedSettingsService.test.ts | 15 +- .../common/managedSettingsFreshness.test.ts | 66 ++ .../browser/policyBlocked.contribution.ts | 10 +- .../browser/sessionsPolicyBlocked.ts | 39 ++ .../browser/sessionsPolicyBlocked.fixture.ts | 14 + src/vs/sessions/test/web.test.ts | 4 +- .../browser/actions/developerActions.ts | 20 +- src/vs/workbench/browser/web.main.ts | 7 +- .../accounts/browser/defaultAccount.ts | 455 ++++++++++++-- .../test/browser/defaultAccount.test.ts | 594 +++++++++++++++++- .../browser/accountPolicyGateContribution.ts | 166 ++++- .../policies/common/accountPolicyService.ts | 23 +- .../accountPolicyGateContribution.test.ts | 251 +++++++- .../test/browser/accountPolicyService.test.ts | 31 +- .../browser/multiplexPolicyService.test.ts | 4 +- .../browser/componentFixtures/fixtureUtils.ts | 4 +- 32 files changed, 1979 insertions(+), 182 deletions(-) create mode 100644 src/vs/platform/policy/common/managedSettingsFreshness.ts create mode 100644 src/vs/platform/policy/test/common/managedSettingsFreshness.test.ts diff --git a/.github/skills/policy-and-managed-settings/github-managed-settings.md b/.github/skills/policy-and-managed-settings/github-managed-settings.md index 55020bc8289..16dfb499d8c 100644 --- a/.github/skills/policy-and-managed-settings/github-managed-settings.md +++ b/.github/skills/policy-and-managed-settings/github-managed-settings.md @@ -372,10 +372,13 @@ constant, configuration policy, or policy-data export. `forceRemoteSettingsRefresh` is not a user configuration setting. It controls whether the server-managed-settings cache may satisfy startup, so VS Code preserves it in the cached raw server -bag and always includes it in the native MDM watch schema. `DefaultAccountProvider` resolves an -explicit native MDM boolean ahead of the cached server value; when the result is `true`, it bypasses -an otherwise-fresh server cache for the first fetch for that account in the current process. The -cache remains available as the normal fetch-failure fallback. +bag and always includes it in the native MDM watch schema. `DefaultAccountProvider` resolves the +control across native MDM, cached server, and managed-file delivery before using the server cache. +When the result is `true`, only a fresh successful server response for the current account, +authentication provider, and endpoint satisfies the requirement. A failed refresh may retain cached +restrictions and the flag itself, but the Account Policy gate keeps AI features disabled until a +retry succeeds. Authentication remains available so users can recover from missing or expired +credentials. Reference tests: - `src/vs/platform/policy/test/common/copilotManagedSettings.test.ts` diff --git a/.github/skills/policy-and-managed-settings/local-testing.md b/.github/skills/policy-and-managed-settings/local-testing.md index 4ea7ca8d271..d3ea0a96e27 100644 --- a/.github/skills/policy-and-managed-settings/local-testing.md +++ b/.github/skills/policy-and-managed-settings/local-testing.md @@ -31,6 +31,14 @@ Use **Clear Policy Cache** when the runtime's fresh managed-settings cache prevents a network request. The live request log confirms whether the client reached the server. +To test `forceRemoteSettingsRefresh` fail-closed behavior, apply the +`customization-lockdown` managed-settings preset and sync once successfully. +Then select the `server-error` preset or choose the `malformed-json`, +`disconnect`, or `timeout` response behavior and sync again. The successful +first response seeds the cached refresh requirement; the second response +exercises HTTP, parse, immediate-network, or client-timeout failure without +manually editing payloads. + Other Copilot clients share the default cache. For deterministic testing, start both Code OSS and the mock server with the same isolated `COPILOT_CACHE_HOME`. diff --git a/scripts/mock-policy-server/README.md b/scripts/mock-policy-server/README.md index 6af646ec933..73617b93ca3 100644 --- a/scripts/mock-policy-server/README.md +++ b/scripts/mock-policy-server/README.md @@ -12,7 +12,7 @@ npm run mock-policy-server Open `http://127.0.0.1:3000`. Managed settings is mocked by default. Use the switch beside each endpoint tab to choose mock or passthrough. Presets apply -immediately; status and JSON edits auto-save. +immediately; response behavior, status, and JSON edits auto-save. The GUI opens on the **Policies** workspace. Select **Setup** in the header to open a modal that guides you through either connection method: @@ -91,9 +91,38 @@ curl -X POST "$BASE/api/state" \ ]}' ``` -A preset sets its status and body and enables mocking. Explicit `status`, `body`, -or `active` values in the same update override the preset. Invalid requests are -rejected before any endpoint changes. +A preset sets the status and body and enables mocking. Response behavior is +configured independently with `mode`, including when a preset and mode are sent +in the same update. Explicit `status`, `body`, or `active` values override the +preset. Invalid requests are rejected before any endpoint changes. Supported +response modes are `json`, `malformed-json`, `disconnect`, and `timeout`. + +### Test fail-closed managed-settings refresh + +First serve a successful policy that enables the forced-refresh requirement and +sync it into VS Code. Then configure an HTTP error preset or a failing response +behavior and sync again. Seeding the requirement first mirrors a real deployment +where the cached control self-perpetuates through an outage. + +```sh +curl -X POST "$BASE/api/state" \ + -H 'Content-Type: application/json' \ + -d '{"endpoint":"managedSettings","preset":"customization-lockdown"}' + +# Run "Developer: Sync Account Policy" in VS Code, then choose one: +curl -X POST "$BASE/api/state" -H 'Content-Type: application/json' \ + -d '{"endpoint":"managedSettings","preset":"server-error"}' +curl -X POST "$BASE/api/state" -H 'Content-Type: application/json' \ + -d '{"endpoint":"managedSettings","mode":"malformed-json","status":200}' +curl -X POST "$BASE/api/state" -H 'Content-Type: application/json' \ + -d '{"endpoint":"managedSettings","mode":"disconnect"}' +curl -X POST "$BASE/api/state" -H 'Content-Type: application/json' \ + -d '{"endpoint":"managedSettings","mode":"timeout"}' +``` + +These configurations exercise HTTP error, malformed response, immediate network +failure, and client-timeout paths respectively. Clear the policy cache if the +request does not appear in **Live Requests**. | Method | Route | Purpose | | --- | --- | --- | diff --git a/scripts/mock-policy-server/endpoints.ts b/scripts/mock-policy-server/endpoints.ts index 8174804de39..f1bef4d0621 100644 --- a/scripts/mock-policy-server/endpoints.ts +++ b/scripts/mock-policy-server/endpoints.ts @@ -32,6 +32,8 @@ export interface EndpointPreset { body: unknown; } +export type EndpointResponseMode = 'json' | 'malformed-json' | 'disconnect' | 'timeout'; + export interface EndpointDef { /** Stable id used by the API + GUI. */ id: string; @@ -239,6 +241,13 @@ declare var MOCK_POLICY_ENDPOINTS: EndpointDef[]; client_version: '1.132.0', minimum_client_version: '1.133.0' } + }, + { + id: 'server-error', + label: 'Server error (500)', + description: 'Returns an HTTP 500 response to exercise the fail-closed HTTP error path.', + status: 500, + body: { error: 'mock_managed_settings_failure' } } ] }, diff --git a/scripts/mock-policy-server/public/app.ts b/scripts/mock-policy-server/public/app.ts index 1765d2a0ae5..6db0c2564bf 100644 --- a/scripts/mock-policy-server/public/app.ts +++ b/scripts/mock-policy-server/public/app.ts @@ -12,6 +12,7 @@ * `endpoints.ts` (loaded via an earlier ` @@ -18,12 +18,14 @@ + + @@ -36,18 +38,18 @@ - - - diff --git a/src/vs/code/browser/workbench/workbench.html b/src/vs/code/browser/workbench/workbench.html index 77881982735..dada9ce894a 100644 --- a/src/vs/code/browser/workbench/workbench.html +++ b/src/vs/code/browser/workbench/workbench.html @@ -2,7 +2,7 @@ - @@ -18,6 +18,7 @@ + @@ -33,11 +34,11 @@ - - diff --git a/src/vs/server/node/webClientServer.ts b/src/vs/server/node/webClientServer.ts index a0567fd399b..ebbfe1c5947 100644 --- a/src/vs/server/node/webClientServer.ts +++ b/src/vs/server/node/webClientServer.ts @@ -27,6 +27,7 @@ import { isString, Mutable } from '../../base/common/types.js'; import { CharCode } from '../../base/common/charCode.js'; import { IExtensionManifest } from '../../platform/extensions/common/extensions.js'; import { ICSSDevelopmentService } from '../../platform/cssDev/node/cssDevService.js'; +import { htmlAttributeEncodeValue } from '../../base/common/strings.js'; const textMimeType: { [ext: string]: string | undefined } = { '.html': 'text/html', @@ -112,6 +113,49 @@ const APP_ROOT = dirname(FileAccess.asFileUri('').fsPath); const STATIC_PATH = `/static`; const CALLBACK_PATH = `/callback`; const WEB_EXTENSION_PATH = `/web-extension-resource`; +const webWorkerExtensionHostIframeScriptSHA = 'sha256-daEgfo2VIXpx2Np71KqCCbkeQwv+68vPrx54XRcbdcs='; + +/** + * Substitutes the `{{...}}` placeholders of a workbench template. Placeholders must only ever + * appear as quoted HTML attribute values, which is what makes attribute encoding sufficient. + */ +export function renderWorkbenchTemplate(template: string, values: Record): string { + return template.replace(/\{\{([^}]+)\}\}/g, (_, key) => htmlAttributeEncodeValue(values[key] ?? 'undefined')); +} + +/** + * Returns whether a reverse proxy supplied prefix is a plain absolute path. Values that could + * change the origin of a redirect or smuggle a query, fragment or control character are rejected. + */ +export function isSafeBasePath(basePath: string): boolean { + return basePath.startsWith('/') + && !basePath.startsWith('//') + && !/[?#\\]|[\u0000-\u001F\u007F]/.test(basePath); +} + +export function createScriptNonce(): string { + return crypto.randomBytes(16).toString('base64url'); +} + +export function createNlsUrl(nlsBaseUrl: string, commit: string | undefined, version: string | undefined, locale: string): string { + return `${nlsBaseUrl}${commit}/${version}/${encodeURIComponent(locale)}/nls.messages.js`; +} + +export function createWorkbenchContentSecurityPolicy(scriptNonce: string, nlsBaseUrl: string | undefined, remoteAuthority: string, useTestResolver: boolean): string { + return [ + 'default-src \'self\';', + 'img-src \'self\' https: data: blob:;', + 'media-src \'self\';', + `script-src 'self' 'unsafe-eval' ${nlsBaseUrl ?? ''} blob: 'nonce-${scriptNonce}' '${webWorkerExtensionHostIframeScriptSHA}' 'sha256-/r7rqQ+yrxt57sxLuQ6AMYcy/lUpvAIzHjIJt/OeLWU=' ${useTestResolver ? '' : `http://${remoteAuthority}`};`, // the sha is the same as in src/vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html + 'child-src \'self\';', + `frame-src 'self' https://*.vscode-cdn.net data:;`, + 'worker-src \'self\' data: blob:;', + 'style-src \'self\' \'unsafe-inline\';', + 'connect-src \'self\' ws: wss: https:;', + 'font-src \'self\' blob:;', + 'manifest-src \'self\';' + ].join(' '); +} export class WebClientServer { @@ -263,7 +307,8 @@ export class WebClientServer { }; // Prefix routes with basePath for clients - const basePath = getFirstHeader('x-forwarded-prefix') || this._basePath; + const forwardedPrefix = getFirstHeader('x-forwarded-prefix'); + const basePath = forwardedPrefix && isSafeBasePath(forwardedPrefix) ? forwardedPrefix : this._basePath; const queryConnectionTokens = parsedUrl.searchParams.getAll(connectionTokenQueryName); if (queryConnectionTokens.length === 1) { @@ -299,7 +344,7 @@ export class WebClientServer { return host; }; - const useTestResolver = (!this._environmentService.isBuilt && this._environmentService.args['use-test-resolver']); + const useTestResolver = (!this._environmentService.isBuilt && !!this._environmentService.args['use-test-resolver']); let remoteAuthority = ( useTestResolver ? 'test+test' @@ -314,7 +359,7 @@ export class WebClientServer { } function asJSON(value: unknown): string { - return JSON.stringify(value).replace(/"/g, '"'); + return JSON.stringify(value); } let _wrapWebWorkerExtHostInIframe: undefined | false = undefined; @@ -388,17 +433,19 @@ export class WebClientServer { let WORKBENCH_NLS_URL: string; if (!locale.startsWith('en') && this._productService.nlsCoreBaseUrl) { WORKBENCH_NLS_BASE_URL = this._productService.nlsCoreBaseUrl; - WORKBENCH_NLS_URL = `${WORKBENCH_NLS_BASE_URL}${this._productService.commit}/${this._productService.version}/${locale}/nls.messages.js`; + WORKBENCH_NLS_URL = createNlsUrl(WORKBENCH_NLS_BASE_URL, this._productService.commit, this._productService.version, locale); } else { WORKBENCH_NLS_URL = ''; // fallback will apply } + const scriptNonce = createScriptNonce(); const values: { [key: string]: string } = { WORKBENCH_WEB_CONFIGURATION: asJSON(workbenchWebConfiguration), WORKBENCH_AUTH_SESSION: authSessionInfo ? asJSON(authSessionInfo) : '', WORKBENCH_WEB_BASE_URL: staticRoute, WORKBENCH_NLS_URL, - WORKBENCH_NLS_FALLBACK_URL: `${staticRoute}/out/nls.messages.js` + WORKBENCH_NLS_FALLBACK_URL: `${staticRoute}/out/nls.messages.js`, + WORKBENCH_SCRIPT_NONCE: scriptNonce }; // DEV --------------------------------------------------------------------------------------- @@ -423,27 +470,13 @@ export class WebClientServer { let data; try { const workbenchTemplate = (await promises.readFile(filePath)).toString(); - data = workbenchTemplate.replace(/\{\{([^}]+)\}\}/g, (_, key) => values[key] ?? 'undefined'); + data = renderWorkbenchTemplate(workbenchTemplate, values); } catch (e) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return void res.end('Not found'); } - const webWorkerExtensionHostIframeScriptSHA = 'sha256-daEgfo2VIXpx2Np71KqCCbkeQwv+68vPrx54XRcbdcs='; - - const cspDirectives = [ - 'default-src \'self\';', - 'img-src \'self\' https: data: blob:;', - 'media-src \'self\';', - `script-src 'self' 'unsafe-eval' ${WORKBENCH_NLS_BASE_URL ?? ''} blob: 'nonce-1nline-m4p' ${this._getScriptCspHashes(data).join(' ')} '${webWorkerExtensionHostIframeScriptSHA}' 'sha256-/r7rqQ+yrxt57sxLuQ6AMYcy/lUpvAIzHjIJt/OeLWU=' ${useTestResolver ? '' : `http://${remoteAuthority}`};`, // the sha is the same as in src/vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html - 'child-src \'self\';', - `frame-src 'self' https://*.vscode-cdn.net data:;`, - 'worker-src \'self\' data: blob:;', - 'style-src \'self\' \'unsafe-inline\';', - 'connect-src \'self\' ws: wss: https:;', - 'font-src \'self\' blob:;', - 'manifest-src \'self\';' - ].join(' '); + const cspDirectives = createWorkbenchContentSecurityPolicy(scriptNonce, WORKBENCH_NLS_BASE_URL, remoteAuthority, useTestResolver); const headers: http.OutgoingHttpHeaders = { 'Content-Type': 'text/html', diff --git a/src/vs/server/test/node/webClientServer.test.ts b/src/vs/server/test/node/webClientServer.test.ts new file mode 100644 index 00000000000..bdde58dd539 --- /dev/null +++ b/src/vs/server/test/node/webClientServer.test.ts @@ -0,0 +1,182 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { createHash } from 'crypto'; +import { promises } from 'fs'; +import { FileAccess } from '../../../base/common/network.js'; +import { htmlAttributeEncodeValue } from '../../../base/common/strings.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { createNlsUrl, createScriptNonce, createWorkbenchContentSecurityPolicy, isSafeBasePath, renderWorkbenchTemplate } from '../../node/webClientServer.js'; + +/** + * Decodes the five entities produced by `htmlAttributeEncodeValue`, the same way a browser + * does when reading a quoted attribute value back via `getAttribute()`. + */ +function decodeHtmlAttribute(value: string): string { + return value.replace(/&(lt|gt|quot|apos|amp);/g, (_, entity) => { + switch (entity) { + case 'lt': return '<'; + case 'gt': return '>'; + case 'quot': return '"'; + case 'apos': return '\''; + case 'amp': return '&'; + } + return _; + }); +} + +function getAttributeValue(html: string, elementId: string): string { + const match = new RegExp(` { + const templatePath = FileAccess.asFileUri('vs/code/browser/workbench/workbench.html').fsPath; + return (await promises.readFile(templatePath)).toString(); +} + +suite('WebClientServer', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('escapes workbench template substitutions', async () => { + const template = await readWorkbenchTemplate(); + const forwardedPrefix = `/'); alert(document.cookie); new URL('x`; + const localeUrl = `https://example.com/fr">)/g)?.length, + inlineScriptWithoutNonceCount: rendered.match(/]*\bsrc=)(?![^>]*\bnonce=)[^>]*>/g)?.length ?? 0, + containsRawForwardedPrefix: rendered.includes(forwardedPrefix), + containsRawLocaleUrl: rendered.includes(localeUrl), + containsEncodedForwardedPrefix: rendered.includes(htmlAttributeEncodeValue(forwardedPrefix)), + containsEncodedLocaleUrl: rendered.includes(htmlAttributeEncodeValue(localeUrl)) + }, { + scriptElementCount: 6, + inlineScriptWithoutNonceCount: 0, + containsRawForwardedPrefix: false, + containsRawLocaleUrl: false, + containsEncodedForwardedPrefix: true, + containsEncodedLocaleUrl: true + }); + }); + + test('round-trips the workbench configuration through attribute encoding', async () => { + const template = await readWorkbenchTemplate(); + const configuration = { + remoteAuthority: 'localhost:3000', + serverBasePath: '/proxy&a=1', + folderUri: { scheme: 'vscode-remote', path: '/it\'s/a "folder"/' } + }; + const baseUrl = '/proxy&a=1/stable/static'; + + const rendered = renderWorkbenchTemplate(template, { + WORKBENCH_WEB_CONFIGURATION: JSON.stringify(configuration), + WORKBENCH_AUTH_SESSION: '', + WORKBENCH_WEB_BASE_URL: baseUrl, + WORKBENCH_NLS_URL: '', + WORKBENCH_NLS_FALLBACK_URL: `${baseUrl}/out/nls.messages.js`, + WORKBENCH_SCRIPT_NONCE: createScriptNonce() + }); + + assert.deepStrictEqual({ + configuration: JSON.parse(decodeHtmlAttribute(getAttributeValue(rendered, 'vscode-workbench-web-configuration'))), + baseUrl: decodeHtmlAttribute(getAttributeValue(rendered, 'vscode-workbench-web-base-url')) + }, { + configuration, + baseUrl + }); + }); + + test('authorizes exactly the rendered inline scripts via the request nonce', async () => { + const template = await readWorkbenchTemplate(); + const scriptNonce = createScriptNonce(); + + const rendered = renderWorkbenchTemplate(template, { + WORKBENCH_WEB_CONFIGURATION: '{}', + WORKBENCH_AUTH_SESSION: '', + WORKBENCH_WEB_BASE_URL: '/static', + WORKBENCH_NLS_URL: '', + WORKBENCH_NLS_FALLBACK_URL: '/static/out/nls.messages.js', + WORKBENCH_SCRIPT_NONCE: scriptNonce + }); + const policy = createWorkbenchContentSecurityPolicy(scriptNonce, undefined, 'localhost:3000', false); + + assert.deepStrictEqual({ + renderedNonces: [...new Set(Array.from(rendered.matchAll(/nonce="([^"]*)"/g), match => match[1]))], + policyAuthorizesRenderedNonce: policy.includes(`'nonce-${scriptNonce}'`) + }, { + renderedNonces: [scriptNonce], + policyAuthorizesRenderedNonce: true + }); + }); + + test('uses a unique nonce without hashing rendered scripts', () => { + const firstNonce = createScriptNonce(); + const secondNonce = createScriptNonce(); + const injectedScriptHash = `'sha256-${createHash('sha256').update('alert(document.cookie)').digest('base64')}'`; + const policy = createWorkbenchContentSecurityPolicy(firstNonce, 'https://example.com/nls/', 'localhost:3000', false); + + assert.deepStrictEqual({ + noncesDiffer: firstNonce !== secondNonce, + hasRequestNonce: policy.includes(`'nonce-${firstNonce}'`), + hasStaticNonce: policy.includes('nonce-1nline-m4p'), + hasInjectedScriptHash: policy.includes(injectedScriptHash) + }, { + noncesDiffer: true, + hasRequestNonce: true, + hasStaticNonce: false, + hasInjectedScriptHash: false + }); + }); + + test('encodes the locale as one NLS URL path segment', () => { + const locale = `fr"> { + const basePaths = [ + '/', + '/proxy', + '/user/123/vscode', + '//evil.com', + '/\\evil.com', + 'https://evil.com', + 'evil.com', + '/proxy?next=https://evil.com', + '/proxy#fragment', + '/proxy\r\nLocation: https://evil.com' + ]; + + assert.deepStrictEqual(basePaths.map(isSafeBasePath), [ + true, + true, + true, + false, + false, + false, + false, + false, + false, + false + ]); + }); +});